Phase 2: document CRUD + auto-save
Backend internal/docs: chi sub-router (list/create/get/update/delete) mounted at /api/docs, scoped to the local user. Create uses RETURNING; update is a COALESCE partial-update so rename and full editor save share one PUT. JSON 404/400 errors; handlers_test.go walks the lifecycle. Frontend: api/client.ts, useAutoSave (1.5s debounce + saveNow flush before doc switch), EditorCore (Tiptap StarterKit/Underline/TextAlign/ Placeholder/CharacterCount) + Toolbar, DocList/DocListItem, StatusBar, and an App.tsx that orchestrates load/select/create/delete with optimistic sidebar patching. content + content_text + word_count are emitted together on every edit. .petal-prose styling (Lora body, Nunito headings). Verified: tsc clean, vite build, go build, full CRUD smoke test incl. CJK title round-trip and SPA serve. Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
This commit is contained in:
192
web/src/App.tsx
192
web/src/App.tsx
@@ -1,45 +1,167 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { api, type DocSummary, type Document } from './api/client'
|
||||
import { useAutoSave } from './hooks/useAutoSave'
|
||||
import { DocList } from './components/DocList/DocList'
|
||||
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
|
||||
import { StatusBar } from './components/StatusBar/StatusBar'
|
||||
|
||||
// Phase 0 placeholder: proves the stack end-to-end — React + Tailwind v4 tokens
|
||||
// rendering, and the Go backend reachable via the /api proxy. Real UI lands in
|
||||
// later phases (DocList, Editor, StatusBar).
|
||||
export default function App() {
|
||||
const [api, setApi] = useState<'checking' | 'ok' | 'down'>('checking')
|
||||
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)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/health')
|
||||
.then((r) => setApi(r.ok ? 'ok' : 'down'))
|
||||
.catch(() => setApi('down'))
|
||||
const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null)
|
||||
|
||||
// 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)
|
||||
if (currentDoc) {
|
||||
patchSummary(currentDoc.id, { word_count: change.word_count })
|
||||
schedule(change)
|
||||
}
|
||||
},
|
||||
[currentDoc, patchSummary, schedule],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-6 px-6 text-center">
|
||||
<div
|
||||
className="flex flex-col items-center gap-3 bg-surface px-12 py-10"
|
||||
style={{ borderRadius: 'var(--radius-card)', boxShadow: 'var(--shadow-soft)' }}
|
||||
<div className="flex h-full flex-col">
|
||||
<header
|
||||
className="flex h-12 shrink-0 items-center gap-2 px-5"
|
||||
style={{ borderBottom: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<span className="text-5xl">🌸</span>
|
||||
<h1 className="text-3xl font-extrabold text-plum">Petal</h1>
|
||||
<p className="max-w-xs text-muted" style={{ fontFamily: 'var(--font-body)' }}>
|
||||
A cozy place to write.
|
||||
</p>
|
||||
<span
|
||||
className="mt-2 inline-flex items-center gap-2 px-4 py-1.5 text-sm font-semibold"
|
||||
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)' }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{
|
||||
background:
|
||||
api === 'ok'
|
||||
? 'var(--color-success)'
|
||||
: api === 'down'
|
||||
? 'var(--color-accent)'
|
||||
: 'var(--color-muted)',
|
||||
}}
|
||||
/>
|
||||
{api === 'checking' ? 'Checking backend…' : api === 'ok' ? 'Backend connected' : 'Backend offline'}
|
||||
</span>
|
||||
<span className="text-xl">🌸</span>
|
||||
<span className="text-lg font-extrabold text-plum">Petal</span>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<DocList
|
||||
docs={docs}
|
||||
selectedId={currentDoc?.id ?? null}
|
||||
onSelect={openDoc}
|
||||
onCreate={handleCreate}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
{currentDoc ? (
|
||||
<>
|
||||
<div className="flex flex-1 flex-col overflow-y-auto px-6 py-8">
|
||||
<div 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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBar wordCount={wordCount} saveStatus={status} />
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user