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

@@ -1,45 +1,167 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { api, type DocSummary, type Document } from './api/client'
import { useAutoSave } from './hooks/useAutoSave'
import { DocList } from './components/DocList/DocList'
import { EditorCore, type EditorChange } from './components/Editor/EditorCore'
import { StatusBar } from './components/StatusBar/StatusBar'
// Phase 0 placeholder: proves the stack end-to-end — React + Tailwind v4 tokens
// rendering, and the Go backend reachable via the /api proxy. Real UI lands in
// later phases (DocList, Editor, StatusBar).
export default function App() {
const [api, setApi] = useState<'checking' | 'ok' | 'down'>('checking')
const [docs, setDocs] = useState<DocSummary[]>([])
const [currentDoc, setCurrentDoc] = useState<Document | null>(null)
const [title, setTitle] = useState('')
const [wordCount, setWordCount] = useState(0)
const [ready, setReady] = useState(false)
useEffect(() => {
fetch('/api/health')
.then((r) => setApi(r.ok ? 'ok' : 'down'))
.catch(() => setApi('down'))
const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null)
// Patch a summary in the sidebar list (optimistic title / word-count updates).
const patchSummary = useCallback((id: string, patch: Partial<DocSummary>) => {
setDocs((prev) => prev.map((d) => (d.id === id ? { ...d, ...patch } : d)))
}, [])
const openDoc = useCallback(
async (id: string) => {
await saveNow() // flush any pending edits to the doc we're leaving
const doc = await api.getDoc(id)
setCurrentDoc(doc)
setTitle(doc.title)
setWordCount(doc.word_count)
},
[saveNow],
)
// Initial load: fetch the list, opening the first doc (or creating one).
const bootedRef = useRef(false)
useEffect(() => {
if (bootedRef.current) return
bootedRef.current = true
;(async () => {
try {
let list = await api.listDocs()
if (list.length === 0) {
const fresh = await api.createDoc()
list = [{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at }]
setDocs(list)
setCurrentDoc(fresh)
setTitle(fresh.title)
setWordCount(0)
} else {
setDocs(list)
await openDoc(list[0].id)
}
} catch (err) {
console.error('failed to load documents', err)
} finally {
setReady(true)
}
})()
}, [openDoc])
const handleCreate = useCallback(async () => {
await saveNow()
const fresh = await api.createDoc()
setDocs((prev) => [
{ id: fresh.id, title: fresh.title, word_count: 0, updated_at: fresh.updated_at },
...prev,
])
setCurrentDoc(fresh)
setTitle(fresh.title)
setWordCount(0)
}, [saveNow])
const handleDelete = useCallback(
async (id: string) => {
await api.deleteDoc(id)
setDocs((prev) => {
const remaining = prev.filter((d) => d.id !== id)
if (currentDoc?.id === id) {
if (remaining.length > 0) {
void openDoc(remaining[0].id)
} else {
setCurrentDoc(null)
setTitle('')
setWordCount(0)
}
}
return remaining
})
},
[currentDoc, openDoc],
)
const handleTitleChange = useCallback(
(value: string) => {
setTitle(value)
if (currentDoc) {
patchSummary(currentDoc.id, { title: value })
schedule({ title: value })
}
},
[currentDoc, patchSummary, schedule],
)
const handleEditorChange = useCallback(
(change: EditorChange) => {
setWordCount(change.word_count)
if (currentDoc) {
patchSummary(currentDoc.id, { word_count: change.word_count })
schedule(change)
}
},
[currentDoc, patchSummary, schedule],
)
return (
<div className="flex h-full flex-col items-center justify-center gap-6 px-6 text-center">
<div
className="flex flex-col items-center gap-3 bg-surface px-12 py-10"
style={{ borderRadius: 'var(--radius-card)', boxShadow: 'var(--shadow-soft)' }}
<div className="flex h-full flex-col">
<header
className="flex h-12 shrink-0 items-center gap-2 px-5"
style={{ borderBottom: '1px solid var(--color-border)' }}
>
<span className="text-5xl">🌸</span>
<h1 className="text-3xl font-extrabold text-plum">Petal</h1>
<p className="max-w-xs text-muted" style={{ fontFamily: 'var(--font-body)' }}>
A cozy place to write.
</p>
<span
className="mt-2 inline-flex items-center gap-2 px-4 py-1.5 text-sm font-semibold"
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)' }}
>
<span
className="inline-block h-2 w-2 rounded-full"
style={{
background:
api === 'ok'
? 'var(--color-success)'
: api === 'down'
? 'var(--color-accent)'
: 'var(--color-muted)',
}}
/>
{api === 'checking' ? 'Checking backend…' : api === 'ok' ? 'Backend connected' : 'Backend offline'}
</span>
<span className="text-xl">🌸</span>
<span className="text-lg font-extrabold text-plum">Petal</span>
</header>
<div className="flex min-h-0 flex-1">
<DocList
docs={docs}
selectedId={currentDoc?.id ?? null}
onSelect={openDoc}
onCreate={handleCreate}
onDelete={handleDelete}
/>
<main className="flex min-w-0 flex-1 flex-col">
{currentDoc ? (
<>
<div className="flex flex-1 flex-col overflow-y-auto px-6 py-8">
<div className="mx-auto flex w-full max-w-[720px] flex-1 flex-col">
<input
value={title}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder="Untitled"
aria-label="Document title"
className="mb-5 w-full bg-transparent text-3xl font-extrabold text-plum focus:outline-none"
style={{ fontFamily: 'var(--font-ui)' }}
/>
<EditorCore
key={currentDoc.id}
docId={currentDoc.id}
initialContent={currentDoc.content}
onChange={handleEditorChange}
/>
</div>
</div>
<StatusBar wordCount={wordCount} saveStatus={status} />
</>
) : (
<div
className="flex flex-1 items-center justify-center text-sm"
style={{ color: 'var(--color-muted)' }}
>
{ready ? 'Create a document to start writing.' : 'Loading…'}
</div>
)}
</main>
</div>
</div>
)

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' }),
}

View File

@@ -0,0 +1,49 @@
import type { DocSummary } from '../../api/client'
import { DocListItem } from './DocListItem'
interface Props {
docs: DocSummary[]
selectedId: string | null
onSelect: (id: string) => void
onCreate: () => void
onDelete: (id: string) => void
}
// DocList is the 260px sidebar: the document browser plus a New button.
export function DocList({ docs, selectedId, onSelect, onCreate, onDelete }: Props) {
return (
<aside
className="flex w-[260px] shrink-0 flex-col gap-2 p-3"
style={{ borderRight: '1px solid var(--color-border)' }}
>
<button
type="button"
onClick={onCreate}
className="flex items-center justify-center gap-2 py-2.5 text-sm font-bold text-white"
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-accent)' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-accent-hover)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'var(--color-accent)')}
>
<span className="text-base leading-none"></span> New Doc
</button>
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto">
{docs.length === 0 ? (
<p className="px-3 py-6 text-center text-xs" style={{ color: 'var(--color-muted)' }}>
No documents yet.
</p>
) : (
docs.map((doc) => (
<DocListItem
key={doc.id}
doc={doc}
active={doc.id === selectedId}
onSelect={() => onSelect(doc.id)}
onDelete={() => onDelete(doc.id)}
/>
))
)}
</div>
</aside>
)
}

View File

@@ -0,0 +1,48 @@
import type { DocSummary } from '../../api/client'
interface Props {
doc: DocSummary
active: boolean
onSelect: () => void
onDelete: () => void
}
// One row in the sidebar: title + word count, a delete affordance on hover, and
// a rose wash when it's the open document.
export function DocListItem({ doc, active, onSelect, onDelete }: Props) {
return (
<div
onClick={onSelect}
className="group flex cursor-pointer items-center gap-2 px-3 py-2.5"
style={{
borderRadius: 'var(--radius-card)',
background: active ? 'var(--color-surface)' : 'transparent',
boxShadow: active ? 'var(--shadow-soft)' : 'none',
}}
>
<div className="min-w-0 flex-1">
<div
className="truncate text-sm font-semibold"
style={{ color: active ? 'var(--color-plum)' : 'var(--color-muted)' }}
>
{doc.title || 'Untitled'}
</div>
<div className="text-xs" style={{ color: 'var(--color-muted)' }}>
{doc.word_count} {doc.word_count === 1 ? 'word' : 'words'}
</div>
</div>
<button
type="button"
aria-label="Delete document"
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
className="flex h-6 w-6 shrink-0 items-center justify-center text-base opacity-0 group-hover:opacity-100"
style={{ borderRadius: 'var(--radius-pill)', color: 'var(--color-muted)' }}
>
×
</button>
</div>
)
}

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>
)
}

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>
)
}

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>
)
}

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 }
}

View File

@@ -53,3 +53,56 @@ body {
button, a, input {
transition: all 200ms ease;
}
/* Editor body — serif Lora for prose, warm plum ink, roomy line height. The
title and headings use the Nunito UI face for contrast. */
.petal-prose {
font-family: var(--font-body);
font-size: 1.125rem;
line-height: 1.75;
color: var(--color-plum);
}
.petal-prose > * + * {
margin-top: 0.9em;
}
.petal-prose h1,
.petal-prose h2,
.petal-prose h3 {
font-family: var(--font-ui);
font-weight: 800;
line-height: 1.3;
}
.petal-prose h1 { font-size: 1.6em; }
.petal-prose h2 { font-size: 1.3em; }
.petal-prose h3 { font-size: 1.1em; }
.petal-prose ul,
.petal-prose ol {
padding-left: 1.4em;
}
.petal-prose ul { list-style: disc; }
.petal-prose ol { list-style: decimal; }
.petal-prose a {
color: var(--color-accent-hover);
text-decoration: underline;
}
.petal-prose blockquote {
border-left: 3px solid var(--color-border);
padding-left: 1em;
color: var(--color-muted);
font-style: italic;
}
.petal-prose code {
font-family: var(--font-mono);
font-size: 0.9em;
background: var(--color-surface-alt);
padding: 0.1em 0.35em;
border-radius: 6px;
}
/* Placeholder shown on the empty first paragraph (Tiptap Placeholder ext). */
.petal-prose p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: var(--color-muted);
float: left;
height: 0;
pointer-events: none;
}