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,74 @@
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Underline from '@tiptap/extension-underline'
import TextAlign from '@tiptap/extension-text-align'
import Placeholder from '@tiptap/extension-placeholder'
import CharacterCount from '@tiptap/extension-character-count'
import { useEffect } from 'react'
import { Toolbar } from '../Toolbar/Toolbar'
export interface EditorChange {
content: string // Tiptap JSON, stringified
content_text: string // flattened plain text for the LLM
word_count: number
}
interface Props {
// Changing docId reloads the editor with that document's content.
docId: string
initialContent: string
onChange: (change: EditorChange) => void
}
// parseDoc turns the stored content string into a Tiptap doc node, tolerating
// the DB's '{}' default and any malformed value by falling back to empty.
function parseDoc(raw: string): object | undefined {
if (!raw) return undefined
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object' && parsed.type === 'doc') return parsed
} catch {
/* fall through to empty */
}
return undefined
}
// EditorCore is the Tiptap instance: StarterKit formatting plus underline, text
// alignment, a placeholder, and character counting (words feed the StatusBar).
export function EditorCore({ docId, initialContent, onChange }: Props) {
const editor = useEditor({
extensions: [
StarterKit,
Underline,
TextAlign.configure({ types: ['heading', 'paragraph'] }),
Placeholder.configure({ placeholder: 'Start writing…' }),
CharacterCount,
],
content: parseDoc(initialContent),
editorProps: {
attributes: { class: 'petal-prose focus:outline-none' },
},
onUpdate: ({ editor }) => {
onChange({
content: JSON.stringify(editor.getJSON()),
content_text: editor.getText(),
word_count: editor.storage.characterCount.words(),
})
},
})
// When the selected document changes, swap in its content without emitting an
// update (false) so loading a doc doesn't trigger a spurious save.
useEffect(() => {
if (!editor) return
editor.commands.setContent(parseDoc(initialContent) ?? '', false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [docId, editor])
return (
<div className="flex flex-1 flex-col">
<Toolbar editor={editor} />
<EditorContent editor={editor} className="flex-1" />
</div>
)
}