import { useCallback, useEffect, useRef, useState } from 'react' import { api, type DocSummary, type Document, type Suggestion, type Tag, type TagColor } from './api/client' import { useAutoSave } from './hooks/useAutoSave' import { 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 { 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 { useVersionWatch } from './hooks/useVersionWatch' import { PetalFall } from './effects/PetalFall' 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() 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, } = 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], ) 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 = 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 titled "… (副本)", // 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 = `${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], ) 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. const handleAccept = useCallback( async (s: Suggestion) => { removeSuggestion(s.id) setAcceptTick((n) => n + 1) try { await api.acceptSuggestion(s.id) } catch (err) { console.error('accept failed', err) } }, [removeSuggestion], ) // 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 { await api.dismissSuggestion(s.id) } catch (err) { console.error('dismiss failed', err) } }, [removeSuggestion], ) // 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). const chimedRef = useRef>(new Set()) useEffect(() => { const fresh = suggestions.filter((s) => !chimedRef.current.has(s.id)) fresh.forEach((s) => chimedRef.current.add(s.id)) 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 (
🌸 Petal
setDrawerOpen(false)} aria-hidden />
{currentDoc ? ( <>
handleTitleChange(e.target.value)} placeholder="Untitled" aria-label="Document title" className="min-w-0 flex-1 bg-transparent text-3xl font-extrabold text-plum focus:outline-none" 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 && }
) }