feat(notes): add NotesList client component with CRUD + filters
This commit is contained in:
parent
ee4dfd79fb
commit
66e255ce30
1 changed files with 407 additions and 0 deletions
407
Workflow/app/notes/NotesList.tsx
Normal file
407
Workflow/app/notes/NotesList.tsx
Normal file
|
|
@ -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<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 (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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue