diff --git a/Workflow/app/notes/NotesList.tsx b/Workflow/app/notes/NotesList.tsx new file mode 100644 index 0000000..2edb092 --- /dev/null +++ b/Workflow/app/notes/NotesList.tsx @@ -0,0 +1,407 @@ +// app/notes/NotesList.tsx +// Client Component. Owns the notes UI state (filter, add-form, edit, +// optimistic CRUD). All server calls go through /api/notes/* JSON routes. + +"use client"; + +import { useMemo, useState } from "react"; +import type { YsterbaardNote } from "@/lib/types"; + +type Filter = "all" | "open" | "done"; + +interface Props { + initialNotes: YsterbaardNote[]; +} + +export default function NotesList({ initialNotes }: Props) { + const [notes, setNotes] = useState(initialNotes); + const [filter, setFilter] = useState("all"); + const [adding, setAdding] = useState(false); + const [editingId, setEditingId] = useState(null); + const [error, setError] = useState(null); + + // Sort: open first (created desc), then done (done_at desc). + const sorted = useMemo(() => { + const open = notes + .filter((n) => !n.is_done) + .slice() + .sort((a, b) => (a.created < b.created ? 1 : -1)); + const done = notes + .filter((n) => n.is_done) + .slice() + .sort((a, b) => { + const ad = a.done_at ?? a.updated ?? a.created; + const bd = b.done_at ?? b.updated ?? b.created; + return ad < bd ? 1 : -1; + }); + return [...open, ...done]; + }, [notes]); + + const filtered = useMemo(() => { + if (filter === "open") return sorted.filter((n) => !n.is_done); + if (filter === "done") return sorted.filter((n) => n.is_done); + return sorted; + }, [sorted, filter]); + + function refresh(updated: YsterbaardNote) { + setNotes((prev) => prev.map((n) => (n.id === updated.id ? updated : n))); + } + + async function toggleDone(n: YsterbaardNote) { + const nextDone = !n.is_done; + // Optimistic + setNotes((prev) => + prev.map((x) => + x.id === n.id + ? { ...x, is_done: nextDone, done_at: nextDone ? new Date().toISOString() : null } + : x, + ), + ); + try { + const res = await fetch(`/api/notes/${encodeURIComponent(n.id)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ is_done: nextDone }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const updated = (await res.json()) as YsterbaardNote; + refresh(updated); + } catch (e) { + setError(`Could not update note: ${e instanceof Error ? e.message : "unknown"}`); + // Rollback + setNotes((prev) => + prev.map((x) => + x.id === n.id + ? { ...x, is_done: !nextDone, done_at: !nextDone ? new Date().toISOString() : null } + : x, + ), + ); + } + } + + async function deleteNote(n: YsterbaardNote) { + if (!confirm(`Delete "${n.title}"?`)) return; + const snapshot = notes; + setNotes((prev) => prev.filter((x) => x.id !== n.id)); + try { + const res = await fetch(`/api/notes/${encodeURIComponent(n.id)}`, { method: "DELETE" }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch (e) { + setError(`Could not delete note: ${e instanceof Error ? e.message : "unknown"}`); + setNotes(snapshot); + } + } + + async function createNote(payload: { title: string; content: string }) { + const res = await fetch("/api/notes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const created = (await res.json()) as YsterbaardNote; + setNotes((prev) => [created, ...prev]); + } + + async function updateNote(id: string, payload: { title?: string; content?: string }) { + const res = await fetch(`/api/notes/${encodeURIComponent(id)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const updated = (await res.json()) as YsterbaardNote; + refresh(updated); + } + + return ( +
+ {error && ( +
+ {error} + +
+ )} + +
+
+ {(["all", "open", "done"] as Filter[]).map((f) => ( + + ))} +
+ {!adding && ( + + )} +
+ + {adding && ( + setAdding(false)} + onSubmit={async (payload) => { + try { + await createNote(payload); + setAdding(false); + } catch (e) { + setError(`Could not create note: ${e instanceof Error ? e.message : "unknown"}`); + } + }} + /> + )} + + {filtered.length === 0 ? ( + + ) : ( +
    + {filtered.map((n) => + editingId === n.id ? ( + setEditingId(null)} + onSave={async (payload) => { + try { + await updateNote(n.id, payload); + setEditingId(null); + } catch (e) { + setError(`Could not save note: ${e instanceof Error ? e.message : "unknown"}`); + } + }} + /> + ) : ( + toggleDone(n)} + onEdit={() => setEditingId(n.id)} + onDelete={() => deleteNote(n)} + /> + ), + )} +
+ )} +
+ ); +} + +function AddForm({ + onCancel, + onSubmit, +}: { + onCancel: () => void; + onSubmit: (payload: { title: string; content: string }) => Promise; +}) { + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + const [busy, setBusy] = useState(false); + return ( +
+ setTitle(e.target.value)} + className="w-full rounded-md border border-border bg-surface-warm px-3 py-2 text-sm font-semibold text-fg placeholder:text-muted" + data-od-id="notes-add-title" + /> +