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:
49
web/src/components/DocList/DocList.tsx
Normal file
49
web/src/components/DocList/DocList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
web/src/components/DocList/DocListItem.tsx
Normal file
48
web/src/components/DocList/DocListItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
74
web/src/components/Editor/EditorCore.tsx
Normal file
74
web/src/components/Editor/EditorCore.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
47
web/src/components/StatusBar/StatusBar.tsx
Normal file
47
web/src/components/StatusBar/StatusBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
139
web/src/components/Toolbar/Toolbar.tsx
Normal file
139
web/src/components/Toolbar/Toolbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user