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:
@@ -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] 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
|
- [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
|
### Phase 2 — Document CRUD + auto-save ← first "it works" milestone ✅
|
||||||
- [ ] Doc handlers: list/create/get/update/delete (`internal/docs/handlers.go`)
|
- [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
|
||||||
- [ ] Frontend DocList sidebar (create/rename/delete)
|
- [x] Frontend DocList sidebar (create/rename/delete) — `DocList`/`DocListItem`, optimistic title/word-count patching
|
||||||
- [ ] Tiptap EditorCore (StarterKit, Underline, TextAlign, Placeholder, CharacterCount)
|
- [x] Tiptap EditorCore (StarterKit, Underline, TextAlign, Placeholder, CharacterCount) + inline `Toolbar` (B/I/U, H1/H2, bullets, align)
|
||||||
- [ ] `useAutoSave` (1.5s debounce) → PUT /api/docs/:id
|
- [x] `useAutoSave` (1.5s debounce) → PUT /api/docs/:id, with `saveNow()` flush before doc switch/create
|
||||||
- [ ] StatusBar: word count + save status
|
- [x] StatusBar: word count + save status (Editing→Saving→Saved, fades after 3s)
|
||||||
- [ ] Keep `content` (Tiptap JSON) and `content_text` (plain) in sync on save
|
- [x] Keep `content` (Tiptap JSON) and `content_text` (plain) in sync on save — editor emits both + word_count together
|
||||||
|
|
||||||
### Phase 3 — LLM grammar checkpoint
|
### Phase 3 — LLM grammar checkpoint
|
||||||
- [ ] `LLMClient` interface + factory (`internal/llm/client.go`)
|
- [ ] `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: 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 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 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).**
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
|
|
||||||
"gitea.parodia.dev/drwily/petal/internal/config"
|
"gitea.parodia.dev/drwily/petal/internal/config"
|
||||||
"gitea.parodia.dev/drwily/petal/internal/db"
|
"gitea.parodia.dev/drwily/petal/internal/db"
|
||||||
|
"gitea.parodia.dev/drwily/petal/internal/docs"
|
||||||
"gitea.parodia.dev/drwily/petal/web"
|
"gitea.parodia.dev/drwily/petal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,6 +37,8 @@ func main() {
|
|||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
_, _ = 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).
|
// Everything else: serve the embedded SPA (with index.html fallback for client routing).
|
||||||
|
|||||||
208
internal/docs/handlers.go
Normal file
208
internal/docs/handlers.go
Normal file
@@ -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") }
|
||||||
103
internal/docs/handlers_test.go
Normal file
103
internal/docs/handlers_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
192
web/src/App.tsx
192
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() {
|
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(() => {
|
const { status, schedule, saveNow } = useAutoSave(currentDoc?.id ?? null)
|
||||||
fetch('/api/health')
|
|
||||||
.then((r) => setApi(r.ok ? 'ok' : 'down'))
|
// Patch a summary in the sidebar list (optimistic title / word-count updates).
|
||||||
.catch(() => setApi('down'))
|
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 (
|
return (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-6 px-6 text-center">
|
<div className="flex h-full flex-col">
|
||||||
<div
|
<header
|
||||||
className="flex flex-col items-center gap-3 bg-surface px-12 py-10"
|
className="flex h-12 shrink-0 items-center gap-2 px-5"
|
||||||
style={{ borderRadius: 'var(--radius-card)', boxShadow: 'var(--shadow-soft)' }}
|
style={{ borderBottom: '1px solid var(--color-border)' }}
|
||||||
>
|
>
|
||||||
<span className="text-5xl">🌸</span>
|
<span className="text-xl">🌸</span>
|
||||||
<h1 className="text-3xl font-extrabold text-plum">Petal</h1>
|
<span className="text-lg font-extrabold text-plum">Petal</span>
|
||||||
<p className="max-w-xs text-muted" style={{ fontFamily: 'var(--font-body)' }}>
|
</header>
|
||||||
A cozy place to write.
|
|
||||||
</p>
|
<div className="flex min-h-0 flex-1">
|
||||||
<span
|
<DocList
|
||||||
className="mt-2 inline-flex items-center gap-2 px-4 py-1.5 text-sm font-semibold"
|
docs={docs}
|
||||||
style={{ borderRadius: 'var(--radius-pill)', background: 'var(--color-surface-alt)' }}
|
selectedId={currentDoc?.id ?? null}
|
||||||
>
|
onSelect={openDoc}
|
||||||
<span
|
onCreate={handleCreate}
|
||||||
className="inline-block h-2 w-2 rounded-full"
|
onDelete={handleDelete}
|
||||||
style={{
|
/>
|
||||||
background:
|
|
||||||
api === 'ok'
|
<main className="flex min-w-0 flex-1 flex-col">
|
||||||
? 'var(--color-success)'
|
{currentDoc ? (
|
||||||
: api === 'down'
|
<>
|
||||||
? 'var(--color-accent)'
|
<div className="flex flex-1 flex-col overflow-y-auto px-6 py-8">
|
||||||
: 'var(--color-muted)',
|
<div className="mx-auto flex w-full max-w-[720px] flex-1 flex-col">
|
||||||
}}
|
<input
|
||||||
/>
|
value={title}
|
||||||
{api === 'checking' ? 'Checking backend…' : api === 'ok' ? 'Backend connected' : 'Backend offline'}
|
onChange={(e) => handleTitleChange(e.target.value)}
|
||||||
</span>
|
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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
51
web/src/api/client.ts
Normal file
51
web/src/api/client.ts
Normal 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' }),
|
||||||
|
}
|
||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
web/src/hooks/useAutoSave.ts
Normal file
66
web/src/hooks/useAutoSave.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -53,3 +53,56 @@ body {
|
|||||||
button, a, input {
|
button, a, input {
|
||||||
transition: all 200ms ease;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user