import { useCallback, useEffect, useRef, useState } from 'react' import { api, type DocSummary, type Document, type Suggestion } from './api/client' import { useAutoSave } from './hooks/useAutoSave' import { useCheckpoint } from './hooks/useCheckpoint' import { useSpellChecker } from './hooks/useSpellChecker' import { DocList } from './components/DocList/DocList' import { EditorCore, type EditorChange } from './components/Editor/EditorCore' import { StatusBar } from './components/StatusBar/StatusBar' import { PetalCompanion } from './components/Companion/PetalCompanion' import { UpdateBanner } from './components/UpdateBanner/UpdateBanner' import { useVersionWatch } from './hooks/useVersionWatch' export default function App() { const updateAvailable = useVersionWatch() const [docs, setDocs] = useState([]) const [currentDoc, setCurrentDoc] = useState(null) const [title, setTitle] = useState('') const [wordCount, setWordCount] = useState(0) 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) const canvasRef = useRef(null) // Monotonic counters the companion watches to react to writing + accepts. const [editTick, setEditTick] = useState(0) const [acceptTick, setAcceptTick] = useState(0) const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null) const { suggestions, checking, voicing, schedule: scheduleCheckpoint, runVoice, removeSuggestion, } = useCheckpoint(currentDoc?.id ?? null) // Browser-side spell checker — loads the en-US dictionary once per session. const { checker: spellChecker, addWord } = useSpellChecker() // 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))) }, []) const openDoc = useCallback( async (id: string) => { 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) }, [saveNow], ) // 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 }] setDocs(list) setCurrentDoc(fresh) setTitle(fresh.title) setWordCount(0) } 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 () => { await saveNow() const fresh = await api.createDoc() setDocs((prev) => [ { id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at }, ...prev, ]) setCurrentDoc(fresh) setTitle(fresh.title) setWordCount(0) }, [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) } } 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) setEditTick((n) => n + 1) if (currentDoc) { patchSummary(currentDoc.id, { word_count: change.word_count }) schedule(change) scheduleCheckpoint() } }, [currentDoc, patchSummary, 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], ) // 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], ) return (
🌸 Petal
{currentDoc ? ( <>
handleTitleChange(e.target.value)} placeholder="Untitled" aria-label="Document title" className="mb-5 w-full 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…'}
)}
{updateAvailable && }
) }