import { useCallback, useEffect, useRef, useState } from 'react' import { api, type DocSummary, type DocUpdate, type Document, type Suggestion, type Tag, type TagColor } from './api/client' import { useAutoSave } from './hooks/useAutoSave' import { findingKey, useCheckpoint } from './hooks/useCheckpoint' import { useSpellChecker } from './hooks/useSpellChecker' import { useTags } from './hooks/useTags' import { DocList } from './components/DocList/DocList' import { EditorCore, type EditorChange } from './components/Editor/EditorCore' import { ToneSelect } from './components/Editor/ToneSelect' import { ChromeStrip } from './components/Editor/ChromeStrip' import { ExportMenu } from './components/Export/ExportMenu' import { HistoryPanel } from './components/History/HistoryPanel' import { GardenPanel } from './components/Garden/GardenPanel' import { StatusBar } from './components/StatusBar/StatusBar' import { PetalCompanion } from './components/Companion/PetalCompanion' import { UpdateBanner } from './components/UpdateBanner/UpdateBanner' import { SignInOverlay } from './components/Auth/SignInOverlay' import { useSession } from './hooks/useSession' import { takeDraft } from './lib/drafts' import { useVersionWatch } from './hooks/useVersionWatch' import { PetalFall } from './effects/PetalFall' import { usePack } from './i18n' import { useNightMode } from './hooks/useNightMode' import { playSuggestionSound } from './audio/sounds' export default function App() { const updateAvailable = useVersionWatch() // Late-night calm mode: dark theme + falling stars instead of petals. The hook // toggles the `petal-night` class on ; we pass the flag to the ambient // layer so the petals become stars. const night = useNightMode() // Who's writing, and whether the server still recognises them. `signedOut` // flips the moment any call comes back 401. const { me, signedOut } = useSession() const t = usePack() // A real account to sign out of, as opposed to the hardcoded local user a // build without auth configured runs as. const account = me && me.id !== 'local' ? { name: me.display_name || me.email } : null const [docs, setDocs] = useState([]) const [currentDoc, setCurrentDoc] = useState(null) const [title, setTitle] = useState('') const [wordCount, setWordCount] = useState(0) // The current document's target tone (steers checkpoint advice) and its live // plain text (drives the expanded writing-stats panel in the StatusBar). const [tone, setTone] = useState('general') const [docText, setDocText] = useState('') const [ready, setReady] = useState(false) // Distraction-free mode: entered on editor focus, collapses the doc-list // sidebar. Escape or a click outside the editor canvas restores it. const [focusMode, setFocusMode] = useState(false) // Mobile drawer: below the tablet breakpoint the sidebar is an overlay toggled // by the header hamburger. Ignored on wide screens (sidebar is always in-flow). const [drawerOpen, setDrawerOpen] = useState(false) const canvasRef = useRef(null) // Monotonic counters the companion watches to react to writing + accepts. const [editTick, setEditTick] = useState(0) const [acceptTick, setAcceptTick] = useState(0) // History drawer visibility, and an epoch bumped on restore to force the // editor to remount with the restored content (its initialContent is read // only on mount). const [historyOpen, setHistoryOpen] = useState(false) const [gardenOpen, setGardenOpen] = useState(false) const [editorEpoch, setEditorEpoch] = useState(0) // Live mirrors of the current doc's editable fields so the "discard blank // drafts on navigation" logic can read the latest values without rebuilding // callbacks on every keystroke. const currentDocRef = useRef(currentDoc) const titleRef = useRef(title) const wordCountRef = useRef(wordCount) currentDocRef.current = currentDoc titleRef.current = title wordCountRef.current = wordCount // A throwaway blank draft: no words and still the default/empty title. We // delete these on navigation rather than leave orphan "Untitled" docs behind. const isBlankDraft = useCallback(() => { const t = titleRef.current.trim() return wordCountRef.current === 0 && (t === '' || t === 'Untitled') }, []) const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null) const { suggestions, checking, voicing, collocating, llmDown, schedule: scheduleCheckpoint, runVoice, runCollocation, removeSuggestion, resolveServerId, } = useCheckpoint(currentDoc?.id ?? null) // Browser-side spell checker — loads the en-US dictionary once per session. const { checker: spellChecker, addWord } = useSpellChecker() // The tag roster (with counts). Assignments live on the doc summaries below. const { tags: tagRoster, refresh: refreshTags, createTag } = useTags() // Patch a summary in the sidebar list (optimistic title / word-count updates). const patchSummary = useCallback((id: string, patch: Partial) => { setDocs((prev) => prev.map((d) => (d.id === id ? { ...d, ...patch } : d))) }, []) // Attach or detach a tag on a document, updating the sidebar optimistically and // refreshing the roster so its counts stay current. Tags are kept sorted by // name to match the server's ordering. const setDocTag = useCallback( async (docId: string, tag: Tag, attach: boolean) => { setDocs((prev) => prev.map((d) => { if (d.id !== docId) return d const without = d.tags.filter((t) => t.id !== tag.id) const next = attach ? [...without, tag] : without next.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })) return { ...d, tags: next } }), ) try { if (attach) await api.assignTag(docId, tag.id) else await api.unassignTag(docId, tag.id) void refreshTags() } catch (err) { console.error('tag assignment failed', err) void refreshTags() } }, [refreshTags], ) const handleToggleTag = useCallback( (docId: string, tag: Tag) => { const doc = docs.find((d) => d.id === docId) const has = doc?.tags.some((t) => t.id === tag.id) ?? false void setDocTag(docId, tag, !has) }, [docs, setDocTag], ) const handleCreateTag = useCallback( async (docId: string, name: string, color: TagColor) => { const tag = await createTag(name, color) if (tag) void setDocTag(docId, tag, true) }, [createTag, setDocTag], ) // If the session lapsed while she was writing, the body that couldn't be // saved was stashed on this device. Opening the document again is where it // comes back: the stashed fields win over the server's older copy, and a save // is scheduled straight away so it stops being local-only. Version history // makes this safe to do silently — the server's copy is one restore away. const pendingRescueRef = useRef<{ id: string; body: DocUpdate } | null>(null) const rescueDraft = useCallback((doc: Document): Document => { const stashed = takeDraft(doc.id) if (!stashed) return doc const patch = Object.fromEntries( Object.entries(stashed.body).filter(([, v]) => v !== undefined), ) if (Object.keys(patch).length === 0) return doc const merged = { ...doc, ...patch } as Document if (merged.content === doc.content && merged.title === doc.title) return doc // Save it, but only once this really is the open document — the auto-save // writes to whichever doc is current when its timer fires. pendingRescueRef.current = { id: doc.id, body: stashed.body } return merged }, []) useEffect(() => { const rescued = pendingRescueRef.current if (rescued && currentDoc?.id === rescued.id) { pendingRescueRef.current = null schedule(rescued.body) } }, [currentDoc?.id, schedule]) const openDoc = useCallback( async (id: string) => { setDrawerOpen(false) // close the mobile drawer when a doc is chosen // Decide the leaving doc's fate before any await mutates state. const leaving = currentDocRef.current const leavingBlank = isBlankDraft() await saveNow() // flush any pending edits to the doc we're leaving const doc = rescueDraft(await api.getDoc(id)) setCurrentDoc(doc) setTitle(doc.title) setWordCount(doc.word_count) setTone(doc.tone || 'general') setDocText(doc.content_text) // Leaving an untouched blank draft for a different doc? Drop it silently // (best-effort cleanup — a 404 just means it's already gone). if (leaving && leaving.id !== id && leavingBlank) { api.deleteDoc(leaving.id).catch(() => {}) setDocs((prev) => prev.filter((d) => d.id !== leaving.id)) } }, [saveNow, isBlankDraft], ) // Initial load: fetch the list, opening the first doc (or creating one). const bootedRef = useRef(false) useEffect(() => { if (bootedRef.current) return bootedRef.current = true ;(async () => { try { let list = await api.listDocs() if (list.length === 0) { const fresh = await api.createDoc() list = [{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at, tags: [] }] setDocs(list) setCurrentDoc(fresh) setTitle(fresh.title) setWordCount(0) setTone(fresh.tone || 'general') setDocText('') } else { setDocs(list) await openDoc(list[0].id) } } catch (err) { console.error('failed to load documents', err) } finally { setReady(true) } })() }, [openDoc]) const handleCreate = useCallback(async () => { // Already sitting on a fresh blank draft? Reuse it instead of stacking // another empty "Untitled" on top. if (currentDocRef.current && isBlankDraft()) return await saveNow() const fresh = await api.createDoc() setDocs((prev) => [ { id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at, tags: [] }, ...prev, ]) setCurrentDoc(fresh) setTitle(fresh.title) setWordCount(0) setTone(fresh.tone || 'general') setDocText('') }, [saveNow, isBlankDraft]) // Duplicate a document: copy its body/tone into a fresh doc under the pack's // "copy" title, // then open the copy. Tags aren't carried over (a fresh start for the copy). const handleDuplicate = useCallback( async (id: string) => { try { await saveNow() // flush in case we're duplicating the open doc const src = await api.getDoc(id) const fresh = await api.createDoc() const dupTitle = t.app.duplicateTitle(src.title?.trim() || 'Untitled') const updated = await api.updateDoc(fresh.id, { title: dupTitle, content: src.content, content_text: src.content_text, tone: src.tone, word_count: src.word_count, }) setDocs((prev) => [ { id: updated.id, title: updated.title, word_count: updated.word_count, updated_at: updated.updated_at, tags: [] }, ...prev, ]) setCurrentDoc(updated) setTitle(updated.title) setWordCount(updated.word_count) setTone(updated.tone || 'general') setDocText(updated.content_text) } catch (err) { console.error('duplicate failed', err) } }, [saveNow], ) const handleDelete = useCallback( async (id: string) => { await api.deleteDoc(id) setDocs((prev) => { const remaining = prev.filter((d) => d.id !== id) if (currentDoc?.id === id) { if (remaining.length > 0) { void openDoc(remaining[0].id) } else { setCurrentDoc(null) setTitle('') setWordCount(0) setTone('general') setDocText('') } } return remaining }) }, [currentDoc, openDoc], ) const handleTitleChange = useCallback( (value: string) => { setTitle(value) if (currentDoc) { patchSummary(currentDoc.id, { title: value }) schedule({ title: value }) } }, [currentDoc, patchSummary, schedule], ) // She took the kitten up on its daily invitation. The prompt becomes the // blank page's title, so the question she agreed to answer stays in front of // her while she answers it — rather than being said once and then gone the // moment the bubble fades. const handleAcceptInvitation = useCallback( (prompt: string) => { if (!currentDoc) return handleTitleChange(prompt) }, [currentDoc, handleTitleChange], ) const handleEditorChange = useCallback( (change: EditorChange) => { setWordCount(change.word_count) setDocText(change.content_text) setEditTick((n) => n + 1) if (currentDoc) { patchSummary(currentDoc.id, { word_count: change.word_count }) schedule(change) scheduleCheckpoint(change.content_text) } }, [currentDoc, patchSummary, schedule, scheduleCheckpoint], ) // Changing the tone persists it and re-runs the checkpoint so Petal's advice // re-tunes to the new register. The auto-save (1.5s) lands before the // checkpoint debounce (4s), so the server reads the updated tone. const handleToneChange = useCallback( (value: string) => { setTone(value) if (currentDoc) { schedule({ tone: value }) scheduleCheckpoint() } }, [currentDoc, schedule, scheduleCheckpoint], ) // Accept applies the replacement in the editor (handled in EditorCore) and // marks the suggestion accepted; dismiss just rejects it. Both drop it locally. // A rule-pack card can be accepted before its row exists — the edit has already // landed either way, so a missing id just means there's nothing to file. const handleAccept = useCallback( async (s: Suggestion) => { removeSuggestion(s.id) setAcceptTick((n) => n + 1) try { const id = await resolveServerId(s) if (id) await api.acceptSuggestion(id) } catch (err) { console.error('accept failed', err) } }, [removeSuggestion, resolveServerId], ) // After restoring a version, swap the restored doc into the editor. Bumping // editorEpoch remounts EditorCore so it picks up the restored content. const handleRestored = useCallback( (doc: Document) => { setCurrentDoc(doc) setTitle(doc.title) setWordCount(doc.word_count) setTone(doc.tone || 'general') setDocText(doc.content_text) patchSummary(doc.id, { title: doc.title, word_count: doc.word_count }) setEditorEpoch((n) => n + 1) }, [patchSummary], ) // Escape always restores the sidebar while in distraction-free mode. useEffect(() => { if (!focusMode) return const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setFocusMode(false) } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [focusMode]) // A pointer-down outside the centered editor canvas (the cream gutters, the // header, the status bar) also restores the sidebar. const handleChromeDown = useCallback((e: React.MouseEvent) => { if (!canvasRef.current?.contains(e.target as Node)) setFocusMode(false) }, []) const handleDismiss = useCallback( async (s: Suggestion) => { removeSuggestion(s.id) try { const id = await resolveServerId(s) if (id) await api.dismissSuggestion(id) } catch (err) { console.error('dismiss failed', err) } }, [removeSuggestion, resolveServerId], ) // Play a soft sound when freshly-checked suggestions arrive — one per distinct // new type, lightly staggered so a batch reads as a little melody rather than // a pile-up. We track which ids we've already chimed for, and only chime for // recently-created suggestions so opening a doc with old pending advice stays // silent (the existing set was created in a past session). // Rule-pack findings are chimed by their wording, not their id: the same fix // appears first as a provisional card and then as its persisted row, and the // writer should hear it once. const chimedRef = useRef>(new Set()) useEffect(() => { const key = (s: Suggestion) => (s.source === 'local' ? `local:${findingKey(s)}` : s.id) const fresh = suggestions.filter((s) => !chimedRef.current.has(key(s))) fresh.forEach((s) => chimedRef.current.add(key(s))) const justMade = fresh.filter( (s) => Date.now() - new Date(s.created_at).getTime() < 12_000, ) if (justMade.length === 0) return const types = [...new Set(justMade.map((s) => s.type))].slice(0, 3) const timers = types.map((t, i) => window.setTimeout(() => playSuggestionSound(t), i * 150), ) return () => timers.forEach((id) => window.clearTimeout(id)) }, [suggestions]) // Forget chimed ids when switching documents so the set can't grow unbounded. useEffect(() => { chimedRef.current = new Set() }, [currentDoc?.id]) return (
🌸 {/* The wordmark is the first thing to go on a small phone. The header holds a hamburger, a name and the garden button, and the garden button's label is a langpack string: 词汇花园 · Garden is 130px where Jardim de palavras · Garden is nearly 200, which is the difference between fitting a 320px screen and scrolling the whole app sideways. Of everything in this row, the one that can be spared is the app's own name — she is already inside the app, and the blossom stays. */} Petal
setDrawerOpen(false)} aria-hidden />
{currentDoc ? ( <>
{/* Title, then the three chrome pills. Their labels are bilingual and don't shrink, so how much width this row wants is a property of the langpack — 历史 is two glyphs where Historique is ten — and on a phone no language's version of it fits. The pills therefore live in a strip that scrolls itself; the title takes its own line below the drawer breakpoint so it keeps its full width. */}
handleTitleChange(e.target.value)} placeholder="Untitled" aria-label="Document title" className="min-w-0 flex-1 basis-full bg-transparent text-3xl font-extrabold text-plum focus:outline-none md:basis-0" style={{ fontFamily: 'var(--font-ui)' }} />
setFocusMode(true)} spellChecker={spellChecker} onAddWord={addWord} />
) : (
{ready ? 'Create a document to start writing.' : 'Loading…'}
)}
{historyOpen && currentDoc && ( setHistoryOpen(false)} onRestored={handleRestored} /> )} {gardenOpen && ( setGardenOpen(false)} onOpenDoc={(id) => { setGardenOpen(false) void openDoc(id) }} /> )} {updateAvailable && } {/* The session lapsed. The editor stays visible behind this — nothing has been taken away — and anything unsaved is already on disk. */} {signedOut && }
) }