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,139 @@
import type { Editor } from '@tiptap/react'
import { useEditorState } from '@tiptap/react'
interface Props {
editor: Editor | null
}
// A formatting button. `active` gets the rose pill treatment so the writer can
// see what's applied at the cursor.
function TBtn({
onClick,
active,
disabled,
label,
children,
}: {
onClick: () => void
active?: boolean
disabled?: boolean
label: string
children: React.ReactNode
}) {
return (
<button
type="button"
aria-label={label}
aria-pressed={active}
disabled={disabled}
onMouseDown={(e) => e.preventDefault()} // keep editor selection
onClick={onClick}
className="flex h-8 min-w-8 items-center justify-center px-2 text-sm font-semibold disabled:opacity-40"
style={{
borderRadius: 'var(--radius-input)',
color: active ? 'var(--color-accent-hover)' : 'var(--color-muted)',
background: active ? 'var(--color-surface-alt)' : 'transparent',
}}
>
{children}
</button>
)
}
const Divider = () => (
<span className="mx-1 h-5 w-px" style={{ background: 'var(--color-border)' }} />
)
// Toolbar renders inline formatting controls bound to the live Tiptap editor.
// useEditorState subscribes to just the flags it reads, so the buttons reflect
// the current selection without re-rendering the whole tree on every keystroke.
export function Toolbar({ editor }: Props) {
const state = useEditorState({
editor,
selector: ({ editor }) =>
editor
? {
bold: editor.isActive('bold'),
italic: editor.isActive('italic'),
underline: editor.isActive('underline'),
h1: editor.isActive('heading', { level: 1 }),
h2: editor.isActive('heading', { level: 2 }),
bullet: editor.isActive('bulletList'),
left: editor.isActive({ textAlign: 'left' }),
center: editor.isActive({ textAlign: 'center' }),
right: editor.isActive({ textAlign: 'right' }),
}
: null,
})
if (!editor || !state) return null
return (
<div
className="mb-4 flex items-center gap-0.5 self-start px-2 py-1.5"
style={{
borderRadius: 'var(--radius-pill)',
background: 'var(--color-surface)',
boxShadow: 'var(--shadow-soft)',
}}
>
<TBtn label="Bold" active={state.bold} onClick={() => editor.chain().focus().toggleBold().run()}>
<span className="font-extrabold">B</span>
</TBtn>
<TBtn label="Italic" active={state.italic} onClick={() => editor.chain().focus().toggleItalic().run()}>
<span className="italic">I</span>
</TBtn>
<TBtn
label="Underline"
active={state.underline}
onClick={() => editor.chain().focus().toggleUnderline().run()}
>
<span className="underline">U</span>
</TBtn>
<Divider />
<TBtn
label="Heading 1"
active={state.h1}
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
>
H1
</TBtn>
<TBtn
label="Heading 2"
active={state.h2}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
H2
</TBtn>
<TBtn
label="Bullet list"
active={state.bullet}
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
</TBtn>
<Divider />
<TBtn
label="Align left"
active={state.left}
onClick={() => editor.chain().focus().setTextAlign('left').run()}
>
</TBtn>
<TBtn
label="Align center"
active={state.center}
onClick={() => editor.chain().focus().setTextAlign('center').run()}
>
</TBtn>
<TBtn
label="Align right"
active={state.right}
onClick={() => editor.chain().focus().setTextAlign('right').run()}
>
</TBtn>
</div>
)
}