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:
prosolis
2026-06-25 20:30:39 -07:00
parent 9c98e97030
commit 5e00cdce88
13 changed files with 1006 additions and 42 deletions

51
web/src/api/client.ts Normal file
View File

@@ -0,0 +1,51 @@
// Thin fetch wrappers for the Petal backend. Everything lives under /api, which
// Vite proxies to the Go server in dev and the binary serves directly in prod.
export interface DocSummary {
id: string
title: string
word_count: number
updated_at: string
}
export interface Document {
id: string
user_id: string
title: string
content: string // Tiptap JSON (stringified)
content_text: string // flattened plain text
word_count: number
created_at: string
updated_at: string
}
// Fields the editor sends on auto-save. All optional so a rename can send title
// alone; the editor sends the full set.
export interface DocUpdate {
title?: string
content?: string
content_text?: string
word_count?: number
}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`/api${path}`, {
headers: { 'Content-Type': 'application/json' },
...init,
})
if (!res.ok) {
const detail = await res.text().catch(() => '')
throw new Error(`${res.status} ${res.statusText}${detail ? `: ${detail}` : ''}`)
}
if (res.status === 204) return undefined as T
return res.json() as Promise<T>
}
export const api = {
listDocs: () => req<DocSummary[]>('/docs'),
createDoc: () => req<Document>('/docs', { method: 'POST' }),
getDoc: (id: string) => req<Document>(`/docs/${id}`),
updateDoc: (id: string, body: DocUpdate) =>
req<Document>(`/docs/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
deleteDoc: (id: string) => req<void>(`/docs/${id}`, { method: 'DELETE' }),
}