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.
408 lines
No EOL
12 KiB
TypeScript
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>
|
|
);
|
|
} |