Companion: bump the corner mascot ~12% (128→144) and make animation resolution robust — fall back to the idle clip when a mood has no clip of its own, and pin the always-asleep cat to one pose so its Lottie never reloads. This stops a bare emoji from replacing the cute companion on idle. Update notifications: serve a build id at /api/version (a hash of the embedded dist/index.html, which changes on every frontend deploy). The client records its load-time id, polls every 90s (and on tab focus), and floats a Mandarin-first "new version available" banner offering a refresh when the id changes. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
267 lines
8.8 KiB
TypeScript
267 lines
8.8 KiB
TypeScript
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<DocSummary[]>([])
|
|
const [currentDoc, setCurrentDoc] = useState<Document | null>(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<HTMLDivElement>(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<DocSummary>) => {
|
|
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 (
|
|
<div className="flex h-full flex-col">
|
|
<header
|
|
onMouseDown={handleChromeDown}
|
|
className="flex h-12 shrink-0 items-center gap-2 px-5"
|
|
style={{ borderBottom: '1px solid var(--color-border)' }}
|
|
>
|
|
<span className="text-xl">🌸</span>
|
|
<span className="text-lg font-extrabold text-plum">Petal</span>
|
|
</header>
|
|
|
|
<div className="flex min-h-0 flex-1">
|
|
<div className={`petal-sidebar shrink-0${focusMode ? ' petal-sidebar-hidden' : ''}`}>
|
|
<DocList
|
|
docs={docs}
|
|
selectedId={currentDoc?.id ?? null}
|
|
onSelect={openDoc}
|
|
onCreate={handleCreate}
|
|
onDelete={handleDelete}
|
|
/>
|
|
</div>
|
|
|
|
<main className="flex min-w-0 flex-1 flex-col">
|
|
{currentDoc ? (
|
|
<>
|
|
<div
|
|
onMouseDown={handleChromeDown}
|
|
className="flex flex-1 flex-col overflow-y-auto px-6 py-8"
|
|
>
|
|
<div ref={canvasRef} className="mx-auto flex w-full max-w-[720px] flex-1 flex-col">
|
|
<input
|
|
value={title}
|
|
onChange={(e) => 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)' }}
|
|
/>
|
|
<EditorCore
|
|
key={currentDoc.id}
|
|
docId={currentDoc.id}
|
|
initialContent={currentDoc.content}
|
|
onChange={handleEditorChange}
|
|
suggestions={suggestions}
|
|
onAccept={handleAccept}
|
|
onDismiss={handleDismiss}
|
|
onVoiceCheck={runVoice}
|
|
voicing={voicing}
|
|
onFocusMode={() => setFocusMode(true)}
|
|
spellChecker={spellChecker}
|
|
onAddWord={addWord}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div onMouseDown={handleChromeDown}>
|
|
<StatusBar
|
|
wordCount={wordCount}
|
|
saveStatus={status}
|
|
checking={checking}
|
|
voicing={voicing}
|
|
/>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div
|
|
className="flex flex-1 items-center justify-center text-sm"
|
|
style={{ color: 'var(--color-muted)' }}
|
|
>
|
|
{ready ? 'Create a document to start writing.' : 'Loading…'}
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
|
|
{updateAvailable && <UpdateBanner />}
|
|
|
|
<PetalCompanion
|
|
wordCount={wordCount}
|
|
saveStatus={status}
|
|
editTick={editTick}
|
|
acceptTick={acceptTick}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|