From 5e00cdce88931ad37d4b722946ceb1c8174bba19 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Thu, 25 Jun 2026 20:30:39 -0700 Subject: [PATCH] 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 --- BUILD_PLAN.md | 15 +- cmd/server/main.go | 3 + internal/docs/handlers.go | 208 +++++++++++++++++++++ internal/docs/handlers_test.go | 103 ++++++++++ web/src/App.tsx | 192 +++++++++++++++---- web/src/api/client.ts | 51 +++++ web/src/components/DocList/DocList.tsx | 49 +++++ web/src/components/DocList/DocListItem.tsx | 48 +++++ web/src/components/Editor/EditorCore.tsx | 74 ++++++++ web/src/components/StatusBar/StatusBar.tsx | 47 +++++ web/src/components/Toolbar/Toolbar.tsx | 139 ++++++++++++++ web/src/hooks/useAutoSave.ts | 66 +++++++ web/src/index.css | 53 ++++++ 13 files changed, 1006 insertions(+), 42 deletions(-) create mode 100644 internal/docs/handlers.go create mode 100644 internal/docs/handlers_test.go create mode 100644 web/src/api/client.ts create mode 100644 web/src/components/DocList/DocList.tsx create mode 100644 web/src/components/DocList/DocListItem.tsx create mode 100644 web/src/components/Editor/EditorCore.tsx create mode 100644 web/src/components/StatusBar/StatusBar.tsx create mode 100644 web/src/components/Toolbar/Toolbar.tsx create mode 100644 web/src/hooks/useAutoSave.ts diff --git a/BUILD_PLAN.md b/BUILD_PLAN.md index 9b9caba..c14ee4f 100644 --- a/BUILD_PLAN.md +++ b/BUILD_PLAN.md @@ -28,13 +28,13 @@ Multi-session build. **Source of truth for what's done and what's next.** Update - [x] Schema includes `voice` in suggestions type CHECK (full spec schema incl. plagiarism_reports, to avoid a later migration) - [x] `db.Open` wired into `cmd/server/main.go`; `db_test.go` covers migrate/seed idempotency, CHECK constraint, FK cascade -### Phase 2 — Document CRUD + auto-save ← first "it works" milestone -- [ ] Doc handlers: list/create/get/update/delete (`internal/docs/handlers.go`) -- [ ] Frontend DocList sidebar (create/rename/delete) -- [ ] Tiptap EditorCore (StarterKit, Underline, TextAlign, Placeholder, CharacterCount) -- [ ] `useAutoSave` (1.5s debounce) → PUT /api/docs/:id -- [ ] StatusBar: word count + save status -- [ ] Keep `content` (Tiptap JSON) and `content_text` (plain) in sync on save +### Phase 2 — Document CRUD + auto-save ← first "it works" milestone ✅ +- [x] Doc handlers: list/create/get/update/delete (`internal/docs/handlers.go`) — chi sub-router mounted at `/api/docs`, all scoped to `local` user, partial-update via COALESCE so rename and full save share one PUT; `handlers_test.go` covers the lifecycle +- [x] Frontend DocList sidebar (create/rename/delete) — `DocList`/`DocListItem`, optimistic title/word-count patching +- [x] Tiptap EditorCore (StarterKit, Underline, TextAlign, Placeholder, CharacterCount) + inline `Toolbar` (B/I/U, H1/H2, bullets, align) +- [x] `useAutoSave` (1.5s debounce) → PUT /api/docs/:id, with `saveNow()` flush before doc switch/create +- [x] StatusBar: word count + save status (Editing→Saving→Saved, fades after 3s) +- [x] Keep `content` (Tiptap JSON) and `content_text` (plain) in sync on save — editor emits both + word_count together ### Phase 3 — LLM grammar checkpoint - [ ] `LLMClient` interface + factory (`internal/llm/client.go`) @@ -72,3 +72,4 @@ Multi-session build. **Source of truth for what's done and what's next.** Update - 2026-06-25: Spec reviewed & amended (voice/grammar decoupled, ctx cap, routes, voice DB type, honey color, string-anchoring). Build plan created. - 2026-06-25: **Phase 0 complete.** Go module + chi server, config loader, React/Vite/Tailwind-v4 scaffold with full design tokens, frontend embedded & served by the binary, verified end-to-end. Toolchain: Go 1.24.4, Node 22, npm 10. Next: **Phase 1 (data layer)** — SQLite via modernc, models, seed `local` user. - 2026-06-25: **Phase 1 complete.** `internal/db` package: modernc.org/sqlite (pulled go toolchain → 1.25), `Open()` does mkdir + WAL/foreign-keys DSN + versioned migration runner + idempotent local-user seed. Models with type/status constants. Wired into `main.go`; tests pass (migrate/seed idempotency, CHECK reject, FK cascade). Verified server boots and writes `petal.db`. Next: **Phase 2 (document CRUD + auto-save)** — first "it works" milestone. +- 2026-06-25: **Phase 2 complete.** Backend `internal/docs`: chi sub-router (list/create/get/update/delete) mounted at `/api/docs`, local-user scoped, RETURNING on create, COALESCE partial-update (one PUT serves rename + full save), 404/400 JSON errors; `handlers_test.go` walks the full lifecycle. Frontend: `api/client.ts`, `useAutoSave` (1.5s debounce + `saveNow` flush), `EditorCore` (Tiptap StarterKit/Underline/TextAlign/Placeholder/CharacterCount) + `Toolbar`, `DocList`/`DocListItem`, `StatusBar`, rewritten `App.tsx` orchestrating load/select/create/delete with optimistic sidebar patching. `.petal-prose` styles (Lora body, Nunito headings). tsc clean, vite build OK, go build OK; smoke-tested full CRUD incl. CJK title round-trip + SPA serve. Next: **Phase 3 (LLM grammar checkpoint).** diff --git a/cmd/server/main.go b/cmd/server/main.go index 40a306d..7aae83d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -12,6 +12,7 @@ import ( "gitea.parodia.dev/drwily/petal/internal/config" "gitea.parodia.dev/drwily/petal/internal/db" + "gitea.parodia.dev/drwily/petal/internal/docs" "gitea.parodia.dev/drwily/petal/web" ) @@ -36,6 +37,8 @@ func main() { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"status":"ok"}`)) }) + + api.Mount("/docs", docs.New(database).Routes()) }) // Everything else: serve the embedded SPA (with index.html fallback for client routing). diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go new file mode 100644 index 0000000..9e6e63a --- /dev/null +++ b/internal/docs/handlers.go @@ -0,0 +1,208 @@ +// Package docs implements the document CRUD HTTP handlers — the create / list / +// read / update / delete surface that backs the editor and its 1.5s auto-save. +// All access is scoped to the single hardcoded local user while auth is deferred. +package docs + +import ( + "database/sql" + "encoding/json" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + + "gitea.parodia.dev/drwily/petal/internal/db" +) + +// Handler holds the dependencies shared by every document route. +type Handler struct { + DB *db.DB +} + +// New constructs a Handler. +func New(database *db.DB) *Handler { + return &Handler{DB: database} +} + +// Routes returns a router mounting the document CRUD endpoints. Mount it under +// "/docs" so the full paths are /api/docs, /api/docs/{id}, etc. +func (h *Handler) Routes() chi.Router { + r := chi.NewRouter() + r.Get("/", h.list) + r.Post("/", h.create) + r.Get("/{id}", h.get) + r.Put("/{id}", h.update) + r.Delete("/{id}", h.delete) + return r +} + +// docSummary is the lightweight shape returned by the list endpoint — enough to +// render the DocList sidebar without shipping every document's full body. +type docSummary struct { + ID string `json:"id"` + Title string `json:"title"` + WordCount int `json:"word_count"` + UpdatedAt string `json:"updated_at"` +} + +// list returns the local user's documents, most-recently-updated first. +func (h *Handler) list(w http.ResponseWriter, r *http.Request) { + rows, err := h.DB.Query( + `SELECT id, title, word_count, updated_at + FROM documents + WHERE user_id = ? + ORDER BY updated_at DESC`, + db.LocalUserID, + ) + if err != nil { + serverError(w, err) + return + } + defer rows.Close() + + out := []docSummary{} // non-nil so an empty list serializes as [] not null + for rows.Next() { + var d docSummary + if err := rows.Scan(&d.ID, &d.Title, &d.WordCount, &d.UpdatedAt); err != nil { + serverError(w, err) + return + } + out = append(out, d) + } + if err := rows.Err(); err != nil { + serverError(w, err) + return + } + writeJSON(w, http.StatusOK, out) +} + +// create inserts a fresh blank document and returns it in full. +func (h *Handler) create(w http.ResponseWriter, r *http.Request) { + var doc db.Document + err := h.DB.QueryRow( + `INSERT INTO documents (user_id) VALUES (?) + RETURNING id, user_id, title, content, content_text, word_count, created_at, updated_at`, + db.LocalUserID, + ).Scan( + &doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText, + &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt, + ) + if err != nil { + serverError(w, err) + return + } + writeJSON(w, http.StatusCreated, doc) +} + +// get returns a single full document by id. +func (h *Handler) get(w http.ResponseWriter, r *http.Request) { + doc, err := h.fetch(chi.URLParam(r, "id")) + if errors.Is(err, sql.ErrNoRows) { + notFound(w) + return + } + if err != nil { + serverError(w, err) + return + } + writeJSON(w, http.StatusOK, doc) +} + +// updateRequest is the auto-save payload. Every field is optional (a pointer) so +// the same endpoint serves both the editor's full save ({content, content_text, +// word_count, title}) and a DocList rename ({title} alone). +type updateRequest struct { + Title *string `json:"title"` + Content *string `json:"content"` + ContentText *string `json:"content_text"` + WordCount *int `json:"word_count"` +} + +// update applies the provided fields to a document and returns the saved row. +// content and content_text are kept in sync by the client and written together. +func (h *Handler) update(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + var req updateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + badRequest(w, "invalid JSON body") + return + } + + res, err := h.DB.Exec( + `UPDATE documents + SET title = COALESCE(?, title), + content = COALESCE(?, content), + content_text = COALESCE(?, content_text), + word_count = COALESCE(?, word_count), + updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND user_id = ?`, + req.Title, req.Content, req.ContentText, req.WordCount, id, db.LocalUserID, + ) + if err != nil { + serverError(w, err) + return + } + if n, _ := res.RowsAffected(); n == 0 { + notFound(w) + return + } + + doc, err := h.fetch(id) + if err != nil { + serverError(w, err) + return + } + writeJSON(w, http.StatusOK, doc) +} + +// delete removes a document (suggestions cascade via the FK). +func (h *Handler) delete(w http.ResponseWriter, r *http.Request) { + res, err := h.DB.Exec( + `DELETE FROM documents WHERE id = ? AND user_id = ?`, + chi.URLParam(r, "id"), db.LocalUserID, + ) + if err != nil { + serverError(w, err) + return + } + if n, _ := res.RowsAffected(); n == 0 { + notFound(w) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// fetch loads one full document scoped to the local user. +func (h *Handler) fetch(id string) (db.Document, error) { + var doc db.Document + err := h.DB.QueryRow( + `SELECT id, user_id, title, content, content_text, word_count, created_at, updated_at + FROM documents + WHERE id = ? AND user_id = ?`, + id, db.LocalUserID, + ).Scan( + &doc.ID, &doc.UserID, &doc.Title, &doc.Content, &doc.ContentText, + &doc.WordCount, &doc.CreatedAt, &doc.UpdatedAt, + ) + return doc, err +} + +// --- small response helpers ------------------------------------------------- + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func errorJSON(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} + +func serverError(w http.ResponseWriter, err error) { + errorJSON(w, http.StatusInternalServerError, err.Error()) +} + +func badRequest(w http.ResponseWriter, msg string) { errorJSON(w, http.StatusBadRequest, msg) } +func notFound(w http.ResponseWriter) { errorJSON(w, http.StatusNotFound, "document not found") } diff --git a/internal/docs/handlers_test.go b/internal/docs/handlers_test.go new file mode 100644 index 0000000..1d0e35e --- /dev/null +++ b/internal/docs/handlers_test.go @@ -0,0 +1,103 @@ +package docs + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "gitea.parodia.dev/drwily/petal/internal/db" +) + +// newTestServer spins up an isolated on-disk database and the docs router. +func newTestServer(t *testing.T) http.Handler { + t.Helper() + database, err := db.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("open db: %v", err) + } + t.Cleanup(func() { database.Close() }) + return New(database).Routes() +} + +func do(t *testing.T, srv http.Handler, method, path, body string) *httptest.ResponseRecorder { + t.Helper() + var r *http.Request + if body != "" { + r = httptest.NewRequest(method, path, bytes.NewBufferString(body)) + } else { + r = httptest.NewRequest(method, path, nil) + } + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, r) + return rec +} + +func TestDocumentCRUD(t *testing.T) { + srv := newTestServer(t) + + // Empty list serializes as []. + rec := do(t, srv, http.MethodGet, "/", "") + if rec.Code != http.StatusOK || bytes.TrimSpace(rec.Body.Bytes())[0] != '[' { + t.Fatalf("list empty: code=%d body=%s", rec.Code, rec.Body) + } + + // Create. + rec = do(t, srv, http.MethodPost, "/", "") + if rec.Code != http.StatusCreated { + t.Fatalf("create: code=%d body=%s", rec.Code, rec.Body) + } + var created db.Document + if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { + t.Fatalf("decode create: %v", err) + } + if created.ID == "" || created.Title != "Untitled" { + t.Fatalf("unexpected created doc: %+v", created) + } + + // Update (auto-save shape) keeps content + content_text + word count together. + body := `{"title":"My Essay","content":"{\"x\":1}","content_text":"hello world","word_count":2}` + rec = do(t, srv, http.MethodPut, "/"+created.ID, body) + if rec.Code != http.StatusOK { + t.Fatalf("update: code=%d body=%s", rec.Code, rec.Body) + } + var updated db.Document + _ = json.Unmarshal(rec.Body.Bytes(), &updated) + if updated.Title != "My Essay" || updated.ContentText != "hello world" || updated.WordCount != 2 { + t.Fatalf("update not applied: %+v", updated) + } + + // Partial update (rename only) leaves other fields intact. + rec = do(t, srv, http.MethodPut, "/"+created.ID, `{"title":"Renamed"}`) + _ = json.Unmarshal(rec.Body.Bytes(), &updated) + if updated.Title != "Renamed" || updated.WordCount != 2 { + t.Fatalf("partial update clobbered fields: %+v", updated) + } + + // Get. + rec = do(t, srv, http.MethodGet, "/"+created.ID, "") + if rec.Code != http.StatusOK { + t.Fatalf("get: code=%d", rec.Code) + } + + // List now has one entry. + rec = do(t, srv, http.MethodGet, "/", "") + var summaries []docSummary + _ = json.Unmarshal(rec.Body.Bytes(), &summaries) + if len(summaries) != 1 || summaries[0].Title != "Renamed" { + t.Fatalf("list after create: %+v", summaries) + } + + // Delete, then 404 on subsequent get/delete. + if rec = do(t, srv, http.MethodDelete, "/"+created.ID, ""); rec.Code != http.StatusNoContent { + t.Fatalf("delete: code=%d", rec.Code) + } + if rec = do(t, srv, http.MethodGet, "/"+created.ID, ""); rec.Code != http.StatusNotFound { + t.Fatalf("get after delete: code=%d", rec.Code) + } + if rec = do(t, srv, http.MethodPut, "/missing", `{"title":"x"}`); rec.Code != http.StatusNotFound { + t.Fatalf("update missing: code=%d", rec.Code) + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 8c3068c..eb48c21 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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([]) + const [currentDoc, setCurrentDoc] = useState(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) => { + 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 ( -
-
+
- 🌸 -

Petal

-

- A cozy place to write. -

- - - {api === 'checking' ? 'Checking backend…' : api === 'ok' ? 'Backend connected' : 'Backend offline'} - + 🌸 + Petal +
+ +
+ + +
+ {currentDoc ? ( + <> +
+
+ 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)' }} + /> + +
+
+ + + ) : ( +
+ {ready ? 'Create a document to start writing.' : 'Loading…'} +
+ )} +
) diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..f5ed881 --- /dev/null +++ b/web/src/api/client.ts @@ -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(path: string, init?: RequestInit): Promise { + 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 +} + +export const api = { + listDocs: () => req('/docs'), + createDoc: () => req('/docs', { method: 'POST' }), + getDoc: (id: string) => req(`/docs/${id}`), + updateDoc: (id: string, body: DocUpdate) => + req(`/docs/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + deleteDoc: (id: string) => req(`/docs/${id}`, { method: 'DELETE' }), +} diff --git a/web/src/components/DocList/DocList.tsx b/web/src/components/DocList/DocList.tsx new file mode 100644 index 0000000..e13323f --- /dev/null +++ b/web/src/components/DocList/DocList.tsx @@ -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 ( + + ) +} diff --git a/web/src/components/DocList/DocListItem.tsx b/web/src/components/DocList/DocListItem.tsx new file mode 100644 index 0000000..a1d0708 --- /dev/null +++ b/web/src/components/DocList/DocListItem.tsx @@ -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 ( +
+
+
+ {doc.title || 'Untitled'} +
+
+ {doc.word_count} {doc.word_count === 1 ? 'word' : 'words'} +
+
+ +
+ ) +} diff --git a/web/src/components/Editor/EditorCore.tsx b/web/src/components/Editor/EditorCore.tsx new file mode 100644 index 0000000..c324bf8 --- /dev/null +++ b/web/src/components/Editor/EditorCore.tsx @@ -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 ( +
+ + +
+ ) +} diff --git a/web/src/components/StatusBar/StatusBar.tsx b/web/src/components/StatusBar/StatusBar.tsx new file mode 100644 index 0000000..c9d0969 --- /dev/null +++ b/web/src/components/StatusBar/StatusBar.tsx @@ -0,0 +1,47 @@ +import type { SaveStatus } from '../../hooks/useAutoSave' + +interface Props { + wordCount: number + saveStatus: SaveStatus +} + +const SAVE_LABEL: Record = { + 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 ( +
+ + {wordCount} {wordCount === 1 ? 'word' : 'words'} + + {label && ( + <> + · + + {saveStatus === 'saved' && ( + + )} + {label} + + + )} +
+ ) +} diff --git a/web/src/components/Toolbar/Toolbar.tsx b/web/src/components/Toolbar/Toolbar.tsx new file mode 100644 index 0000000..7865652 --- /dev/null +++ b/web/src/components/Toolbar/Toolbar.tsx @@ -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 ( + + ) +} + +const Divider = () => ( + +) + +// 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 ( +
+ editor.chain().focus().toggleBold().run()}> + B + + editor.chain().focus().toggleItalic().run()}> + I + + editor.chain().focus().toggleUnderline().run()} + > + U + + + editor.chain().focus().toggleHeading({ level: 1 }).run()} + > + H1 + + editor.chain().focus().toggleHeading({ level: 2 }).run()} + > + H2 + + editor.chain().focus().toggleBulletList().run()} + > + • + + + editor.chain().focus().setTextAlign('left').run()} + > + ⇤ + + editor.chain().focus().setTextAlign('center').run()} + > + ↔ + + editor.chain().focus().setTextAlign('right').run()} + > + ⇥ + +
+ ) +} diff --git a/web/src/hooks/useAutoSave.ts b/web/src/hooks/useAutoSave.ts new file mode 100644 index 0000000..3bc95ef --- /dev/null +++ b/web/src/hooks/useAutoSave.ts @@ -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('idle') + + const debounceRef = useRef>(undefined) + const fadeRef = useRef>(undefined) + const pendingRef = useRef(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 } +} diff --git a/web/src/index.css b/web/src/index.css index 01635a9..5485fd2 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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; +}