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,47 @@
import type { SaveStatus } from '../../hooks/useAutoSave'
interface Props {
wordCount: number
saveStatus: SaveStatus
}
const SAVE_LABEL: Record<SaveStatus, string> = {
idle: '',
pending: 'Editing…',
saving: 'Saving…',
saved: 'Saved just now',
error: "Couldn't save",
}
// StatusBar is the slim footer: word count on the left, save state on the right.
// The checkpoint dot (grammar pass) joins it in Phase 3.
export function StatusBar({ wordCount, saveStatus }: Props) {
const label = SAVE_LABEL[saveStatus]
return (
<footer
className="flex h-9 shrink-0 items-center gap-3 px-6 text-xs"
style={{ borderTop: '1px solid var(--color-border)', color: 'var(--color-muted)' }}
>
<span>
{wordCount} {wordCount === 1 ? 'word' : 'words'}
</span>
{label && (
<>
<span aria-hidden>·</span>
<span
className="inline-flex items-center gap-1.5"
style={{ color: saveStatus === 'error' ? 'var(--color-accent)' : undefined }}
>
{saveStatus === 'saved' && (
<span
className="inline-block h-1.5 w-1.5 rounded-full"
style={{ background: 'var(--color-success)' }}
/>
)}
{label}
</span>
</>
)}
</footer>
)
}