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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user