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

View File

@@ -0,0 +1,66 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { api, type DocUpdate } from '../api/client'
export type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error'
const DEBOUNCE_MS = 1500
const SAVED_FADE_MS = 3000
// useAutoSave debounces document saves. Call schedule() on every edit; it fires
// PUT /api/docs/:id 1.5s after the last change. status drives the StatusBar:
// pending → saving → saved (fades to idle after 3s).
export function useAutoSave(docId: string | null) {
const [status, setStatus] = useState<SaveStatus>('idle')
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const fadeRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const pendingRef = useRef<DocUpdate | null>(null)
// Latest doc id, read inside the timer so a doc switch doesn't save to the old one.
const docIdRef = useRef(docId)
docIdRef.current = docId
const flush = useCallback(async () => {
const id = docIdRef.current
const body = pendingRef.current
pendingRef.current = null
if (!id || !body) return
setStatus('saving')
try {
await api.updateDoc(id, body)
setStatus('saved')
clearTimeout(fadeRef.current)
fadeRef.current = setTimeout(() => setStatus('idle'), SAVED_FADE_MS)
} catch (err) {
console.error('auto-save failed', err)
setStatus('error')
}
}, [])
const schedule = useCallback(
(update: DocUpdate) => {
pendingRef.current = { ...pendingRef.current, ...update }
setStatus('pending')
clearTimeout(fadeRef.current)
clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(flush, DEBOUNCE_MS)
},
[flush],
)
// Save any pending edits immediately (e.g. before switching documents).
const saveNow = useCallback(() => {
clearTimeout(debounceRef.current)
return flush()
}, [flush])
useEffect(
() => () => {
clearTimeout(debounceRef.current)
clearTimeout(fadeRef.current)
},
[],
)
return { status, schedule, saveNow }
}