Workflow-App/Workflow/app/notes/NotesList.tsx
sjcoetzeesj-coder f5c7960c6f fix(notes): sort by -id instead of -created (PB 400 on fresh collection sort=created)
PB returns 400 when sorting a freshly imported collection by autodate system
fields (created, updated) — those fields aren't yet exposed for sorting.
id always works.
2026-09-17 13:19:15 +02:00

408 lines
No EOL
12 KiB
TypeScript

// 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<YsterbaardNote[]>(initialNotes);
const [filter, setFilter] = useState<Filter>("all");
const [adding, setAdding] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Sort: open first (id desc — newest first), then done (done_at desc, fallback id desc).
const sorted = useMemo(() => {
const open = notes
.filter((n) => !n.is_done)
.slice()
.sort((a, b) => (a.id < b.id ? 1 : -1));
const done = notes
.filter((n) => n.is_done)
.slice()
.sort((a, b) => {
const ad = a.done_at ?? "";
const bd = b.done_at ?? "";
if (ad !== bd) return ad < bd ? 1 : -1;
return a.id < b.id ? 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 (
<div className="container-x py-6 space-y-4">
{error && (
<div className="rounded-md border border-amber-300 bg-amber-50 text-amber-900 px-3 py-2 text-sm">
{error}
<button className="ml-2 underline" onClick={() => setError(null)}>
dismiss
</button>
</div>
)}
<div className="flex items-center justify-between gap-3">
<div className="inline-flex rounded-md border border-border bg-surface-warm p-0.5">
{(["all", "open", "done"] as Filter[]).map((f) => (
<button
key={f}
type="button"
onClick={() => setFilter(f)}
data-od-id={`notes-filter-${f}`}
className={
"px-3 py-1 text-xs font-semibold rounded " +
(filter === f
? "bg-surface text-fg"
: "text-muted hover:text-fg")
}
>
{f === "all" ? "All" : f === "open" ? "Open" : "Done"}
</button>
))}
</div>
{!adding && (
<button
type="button"
onClick={() => setAdding(true)}
data-od-id="notes-add"
className="inline-flex items-center gap-1.5 rounded-md bg-accent text-accent-on px-3 py-1.5 text-sm font-semibold"
>
+ Add note
</button>
)}
</div>
{adding && (
<AddForm
onCancel={() => 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 ? (
<EmptyState filter={filter} />
) : (
<ul className="space-y-2">
{filtered.map((n) =>
editingId === n.id ? (
<EditRow
key={n.id}
note={n}
onCancel={() => 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"}`);
}
}}
/>
) : (
<Row
key={n.id}
note={n}
onToggle={() => toggleDone(n)}
onEdit={() => setEditingId(n.id)}
onDelete={() => deleteNote(n)}
/>
),
)}
</ul>
)}
</div>
);
}
function AddForm({
onCancel,
onSubmit,
}: {
onCancel: () => void;
onSubmit: (payload: { title: string; content: string }) => Promise<void>;
}) {
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [busy, setBusy] = useState(false);
return (
<div className="rounded-md border border-border bg-surface p-4 space-y-2">
<input
autoFocus
type="text"
placeholder="Note title"
value={title}
onChange={(e) => 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"
/>
<textarea
placeholder="Details (optional)"
value={content}
onChange={(e) => setContent(e.target.value)}
rows={3}
className="w-full rounded-md border border-border bg-surface-warm px-3 py-2 text-sm text-fg placeholder:text-muted"
data-od-id="notes-add-content"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
disabled={busy}
className="px-3 py-1.5 text-sm font-semibold text-muted hover:text-fg"
>
Cancel
</button>
<button
type="button"
disabled={busy || !title.trim()}
onClick={async () => {
setBusy(true);
try {
await onSubmit({ title: title.trim(), content: content.trim() });
} finally {
setBusy(false);
}
}}
className="rounded-md bg-accent text-accent-on px-3 py-1.5 text-sm font-semibold disabled:opacity-50"
data-od-id="notes-add-save"
>
Save
</button>
</div>
</div>
);
}
function Row({
note,
onToggle,
onEdit,
onDelete,
}: {
note: YsterbaardNote;
onToggle: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
return (
<li
className={
"rounded-md border border-border bg-surface px-3 py-2.5 flex items-start gap-3 " +
(note.is_done ? "opacity-70" : "")
}
data-od-id={`notes-row-${note.id}`}
>
<input
type="checkbox"
checked={note.is_done}
onChange={onToggle}
aria-label={note.is_done ? "Mark as not done" : "Mark as done"}
className="mt-1 h-4 w-4 shrink-0 cursor-pointer"
data-od-id={`notes-toggle-${note.id}`}
/>
<div className="flex-1 min-w-0">
<div
className={
"font-semibold text-fg break-words " +
(note.is_done ? "line-through text-muted" : "")
}
>
{note.title}
</div>
{note.content ? (
<div
className={
"text-sm mt-0.5 break-words whitespace-pre-wrap " +
(note.is_done ? "text-muted line-through" : "text-muted")
}
>
{note.content}
</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
onClick={onEdit}
className="px-2 py-1 text-xs font-semibold text-muted hover:text-fg"
data-od-id={`notes-edit-${note.id}`}
>
Edit
</button>
<button
type="button"
onClick={onDelete}
className="px-2 py-1 text-xs font-semibold text-muted hover:text-red-700"
data-od-id={`notes-delete-${note.id}`}
>
Delete
</button>
</div>
</li>
);
}
function EditRow({
note,
onCancel,
onSave,
}: {
note: YsterbaardNote;
onCancel: () => void;
onSave: (payload: { title: string; content: string }) => Promise<void>;
}) {
const [title, setTitle] = useState(note.title);
const [content, setContent] = useState(note.content ?? "");
const [busy, setBusy] = useState(false);
return (
<li className="rounded-md border border-accent bg-surface p-4 space-y-2">
<input
autoFocus
type="text"
value={title}
onChange={(e) => 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"
data-od-id={`notes-edit-title-${note.id}`}
/>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
rows={3}
className="w-full rounded-md border border-border bg-surface-warm px-3 py-2 text-sm text-fg"
data-od-id={`notes-edit-content-${note.id}`}
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onCancel}
disabled={busy}
className="px-3 py-1.5 text-sm font-semibold text-muted hover:text-fg"
>
Cancel
</button>
<button
type="button"
disabled={busy || !title.trim()}
onClick={async () => {
setBusy(true);
try {
await onSave({ title: title.trim(), content: content.trim() });
} finally {
setBusy(false);
}
}}
className="rounded-md bg-accent text-accent-on px-3 py-1.5 text-sm font-semibold disabled:opacity-50"
data-od-id={`notes-edit-save-${note.id}`}
>
Save
</button>
</div>
</li>
);
}
function EmptyState({ filter }: { filter: Filter }) {
const msg =
filter === "open"
? "No open notes. Great work."
: filter === "done"
? "No done notes yet."
: "No notes yet. Click + Add note to get started.";
return (
<div className="rounded-md border border-dashed border-border bg-surface-warm px-4 py-8 text-center text-sm text-muted">
{msg}
</div>
);
}