feat(web): lists, groupings and threads with search-to-add, reorder and grouping rating

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 22:20:10 +01:00
co-authored by Claude Opus 5.5
parent ec39491f47
commit bbd04a5577
3 changed files with 360 additions and 0 deletions
+3
View File
@@ -8,6 +8,7 @@ import { Shell } from "./components/Shell.tsx";
import { useMe } from "./hooks.ts";
import { Changes } from "./routes/changes.tsx";
import { Library } from "./routes/library.tsx";
import { Lists } from "./routes/lists.tsx";
import { Log } from "./routes/log.tsx";
import { Login } from "./routes/login.tsx";
import { NotYet } from "./routes/not-yet.tsx";
@@ -61,6 +62,8 @@ function App() {
<Route path="/entries/:id">{(p) => <Library selectedId={p.id} />}</Route>
<Route path="/changes" component={Changes} />
<Route path="/log" component={Log} />
<Route path="/lists">{() => <Lists selectedId={null} />}</Route>
<Route path="/lists/:id">{(p) => <Lists selectedId={p.id} />}</Route>
<Route path="/sources" component={Sources} />
<Route path="/sources/:id">{(p) => <SourceDetail key={p.id} id={p.id} />}</Route>
<Route path="/:page/*?">{(p) => <NotYet page={p.page} />}</Route>
+316
View File
@@ -0,0 +1,316 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState, type FormEvent } from "react";
import { Link, useLocation } from "wouter";
import { api, type ListKind, type ListWithItems } from "../api.ts";
import { Cover } from "../components/Cover.tsx";
import { Icon } from "../components/Icon.tsx";
import { LoadError } from "../components/LoadError.tsx";
import { StarRating } from "../components/StarRating.tsx";
import { friendlyError, mediaTypeLabel } from "../format.ts";
import { useDebounced } from "../hooks.ts";
import { useRatingScale } from "../settings.ts";
const KINDS: { kind: ListKind; label: string; hint: string }[] = [
{ kind: "list", label: "List", hint: "Things in an order you choose." },
{ kind: "grouping", label: "Grouping", hint: "A set you rate as a whole, like a box set or a trilogy." },
{ kind: "thread", label: "Thread", hint: "A line that connects things across media." },
];
const kindLabel = (k: ListKind) => KINDS.find((x) => x.kind === k)?.label ?? k;
/** Lists, groupings and threads: the index on the left, the open one on the right. */
export function Lists({ selectedId }: { selectedId: string | null }) {
const lists = useQuery({ queryKey: ["lists"], queryFn: api.lists });
return (
<div className={`library${selectedId ? " has-selection" : ""}`}>
<section className="list-pane" aria-label="Your lists">
<div className="list-tools">
<h1 className="pane-title">Lists</h1>
<NewList />
</div>
{lists.error && lists.data && <LoadError error={lists.error} retry={() => void lists.refetch()} stale className="in-list" />}
{lists.isPending ? (
<p className="list-note" aria-busy="true">Loading…</p>
) : lists.error && !lists.data ? (
<div className="list-note"><LoadError error={lists.error} retry={() => void lists.refetch()} /></div>
) : lists.data!.length === 0 ? (
<div className="list-note">
<p><strong>No lists yet.</strong> Name one above. A list can hold books, games, shows and albums side by side.</p>
</div>
) : (
<ul className="list">
{lists.data!.map((l) => (
<li key={l.id}>
<Link href={`/lists/${l.id}`} className="row list-row" aria-current={l.id === selectedId ? "page" : undefined}>
<span className={`kind-mark k-${l.kind}`} aria-hidden="true" />
<span>
<span className="title">{l.name}</span>
<span className="meta">{kindLabel(l.kind)}{l.description ? ` · ${l.description}` : ""}</span>
</span>
<span />
</Link>
</li>
))}
</ul>
)}
</section>
<section className="detail-pane" aria-label="List">
{selectedId ? (
<ListDetail key={selectedId} id={selectedId} />
) : (
<div className="detail-empty"><p><strong>Pick a list</strong>Or make a new one: a reading order, a box set to rate as a whole, or a thread across media.</p></div>
)}
</section>
</div>
);
}
function NewList() {
const qc = useQueryClient();
const [, navigate] = useLocation();
const [kind, setKind] = useState<ListKind>("list");
const [error, setError] = useState<string | null>(null);
const create = useMutation({
mutationFn: api.createList,
onSuccess: (l) => {
void qc.invalidateQueries({ queryKey: ["lists"] });
navigate(`/lists/${l.id}`);
},
onError: (e) => setError(friendlyError(e)),
});
function submit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const form = e.currentTarget;
const name = String(new FormData(form).get("name") ?? "").trim();
if (!name) return setError("Give the list a name.");
setError(null);
create.mutate({ name, kind }, { onSuccess: () => form.reset() });
}
return (
<form className="new-list" onSubmit={submit}>
<label className="sr-only" htmlFor="new-list-name">New list name</label>
<div className="quick">
<span className="plus" aria-hidden="true"><Icon name="plus" /></span>
<input id="new-list-name" name="name" className="input" placeholder="New list: give it a name" maxLength={500} autoComplete="off" readOnly={create.isPending} />
</div>
<div className="new-list-row">
<div className="kind-chips" role="radiogroup" aria-label="Kind">
{KINDS.map((k) => (
<button key={k.kind} type="button" role="radio" aria-checked={kind === k.kind} className="chip" title={k.hint} onClick={() => setKind(k.kind)}>
{k.label}
</button>
))}
</div>
<button type="submit" className="btn small primary" disabled={create.isPending}>Create</button>
</div>
<p className="hint">{KINDS.find((k) => k.kind === kind)!.hint}</p>
{error && <p className="error-text" role="alert">{error}</p>}
</form>
);
}
function ListDetail({ id }: { id: string }) {
const qc = useQueryClient();
const [, navigate] = useLocation();
const scale = useRatingScale();
const key = ["list", id];
const list = useQuery({ queryKey: key, queryFn: () => api.list(id) });
const [error, setError] = useState<string | null>(null);
const [confirming, setConfirming] = useState(false);
const refresh = () => {
void qc.invalidateQueries({ queryKey: key });
void qc.invalidateQueries({ queryKey: ["lists"] });
};
const patch = useMutation({
mutationKey: ["list-patch", id],
scope: { id: `list:${id}` },
mutationFn: (b: Parameters<typeof api.patchList>[1]) => api.patchList(id, b),
onMutate: async (b) => {
await qc.cancelQueries({ queryKey: key });
const prev = qc.getQueryData<ListWithItems>(key);
if (prev) qc.setQueryData<ListWithItems>(key, { ...prev, ...b });
return { prev };
},
onError: (e, _b, ctx) => {
if (ctx?.prev) qc.setQueryData(key, ctx.prev);
setError(friendlyError(e));
},
onSuccess: () => setError(null),
onSettled: refresh,
});
const move = useMutation({
scope: { id: `list:${id}` },
mutationFn: ({ itemId, to }: { itemId: string; to: number }) => api.moveListItem(itemId, to),
onMutate: async ({ itemId, to }) => {
await qc.cancelQueries({ queryKey: key });
const prev = qc.getQueryData<ListWithItems>(key);
if (prev) {
const items = [...prev.items];
const from = items.findIndex((i) => i.id === itemId);
const [it] = items.splice(from, 1);
items.splice(to, 0, it!);
qc.setQueryData<ListWithItems>(key, { ...prev, items: items.map((i, n) => ({ ...i, position: n })) });
}
return { prev };
},
onError: (e, _v, ctx) => {
if (ctx?.prev) qc.setQueryData(key, ctx.prev);
setError(friendlyError(e));
},
onSettled: refresh,
});
const removeItem = useMutation({ mutationFn: api.removeListItem, onSuccess: refresh, onError: (e) => setError(friendlyError(e)) });
const del = useMutation({
mutationFn: () => api.deleteList(id),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["lists"] });
navigate("/lists", { replace: true });
},
onError: (e) => setError(friendlyError(e)),
});
const [said, setSaid] = useState("");
if (list.isPending) return <div className="detail" aria-busy="true" />;
if (!list.data) {
return (
<div className="detail">
<Link href="/lists" className="btn ghost back"><Icon name="back" /> Lists</Link>
<LoadError error={list.error} retry={() => void list.refetch()} />
</div>
);
}
const l = list.data;
const n = l.items.length;
return (
<article className="detail list-detail">
<Link href="/lists" className="btn ghost back"><Icon name="back" /> Lists</Link>
{list.error && <LoadError error={list.error} retry={() => void list.refetch()} stale />}
<header className="list-head">
<label className="sr-only" htmlFor="list-name">Name</label>
<input
id="list-name"
className="title-input"
defaultValue={l.name}
key={l.name}
maxLength={500}
onBlur={(e) => {
const v = e.target.value.trim();
if (!v) e.target.value = l.name;
else if (v !== l.name) patch.mutate({ name: v });
}}
onKeyDown={(e) => e.key === "Enter" && e.currentTarget.blur()}
/>
<div className="det-controls">
<label className="sr-only" htmlFor="list-kind">Kind</label>
<select id="list-kind" className="select" value={l.kind} onChange={(e) => patch.mutate({ kind: e.target.value as ListKind })}>
{KINDS.map((k) => <option key={k.kind} value={k.kind}>{k.label}</option>)}
</select>
<span className="m tabular">{n === 1 ? "1 thing" : `${n} things`}</span>
{l.kind === "grouping" && <StarRating value={l.rating} scale={scale} label={`Rating for ${l.name}`} onChange={(v) => patch.mutate({ rating: v })} />}
</div>
<label className="sr-only" htmlFor="list-desc">Description</label>
<textarea
id="list-desc"
className="desc-input"
rows={2}
placeholder="Add a description"
defaultValue={l.description}
key={`d:${l.description}`}
onBlur={(e) => e.target.value !== l.description && patch.mutate({ description: e.target.value })}
/>
</header>
<p className="sr-only" role="status" aria-live="polite">{said}</p>
{error && <p className="form-error" role="alert">{error}</p>}
{n === 0 ? (
<p className="empty-line list-empty">Nothing here yet. Search below to add something.</p>
) : (
<ol className="items">
{l.items.map((it, i) => (
<li key={it.id} className="item">
<span className="pos tabular" aria-hidden="true">{i + 1}</span>
<Cover url={it.work.coverUrl} mediaType={it.work.mediaType} draft={false} size="small" />
<span className="item-main">
<span className="t">{it.work.title}</span>
<span className="m">{mediaTypeLabel(it.work.mediaType)}{it.work.year ? ` · ${it.work.year}` : ""}</span>
</span>
<span className="item-acts">
<button type="button" className="btn ghost small icon-btn" aria-label={`Move ${it.work.title} up`} disabled={i === 0} onClick={() => (move.mutate({ itemId: it.id, to: i - 1 }), setSaid(`${it.work.title} moved to ${i}.`))}>
<svg className="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 15l6-6 6 6" /></svg>
</button>
<button type="button" className="btn ghost small icon-btn" aria-label={`Move ${it.work.title} down`} disabled={i === n - 1} onClick={() => (move.mutate({ itemId: it.id, to: i + 1 }), setSaid(`${it.work.title} moved to ${i + 2}.`))}>
<svg className="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9l6 6 6-6" /></svg>
</button>
<button type="button" className="btn ghost small icon-btn" aria-label={`Remove ${it.work.title}`} onClick={() => removeItem.mutate(it.id)}>
<Icon name="x" />
</button>
</span>
</li>
))}
</ol>
)}
<AddWork listId={id} have={new Set(l.items.map((i) => i.workId))} onAdded={refresh} />
<div className="list-foot">
{confirming ? (
<div className="form-actions">
<span className="hint">Delete “{l.name}”? The things in it stay in your library.</span>
<button type="button" className="btn danger small" onClick={() => del.mutate()} disabled={del.isPending}>Delete list</button>
<button type="button" className="btn ghost small" onClick={() => setConfirming(false)}>Keep it</button>
</div>
) : (
<button type="button" className="btn ghost small danger" onClick={() => setConfirming(true)}>Delete list…</button>
)}
</div>
</article>
);
}
function AddWork({ listId, have, onAdded }: { listId: string; have: Set<string>; onAdded: () => void }) {
const [q, setQ] = useState("");
const dq = useDebounced(q.trim());
const [error, setError] = useState<string | null>(null);
const results = useQuery({ queryKey: ["works-search", dq], queryFn: () => api.searchWorks(dq), enabled: dq.length > 0 });
const add = useMutation({
mutationFn: (workId: string) => api.addListItem(listId, workId),
onSuccess: () => (setError(null), onAdded()),
onError: (e) => setError(friendlyError(e)),
});
useEffect(() => setError(null), [dq]);
return (
<section className="box add-work" aria-labelledby="add-work">
<h2 id="add-work">Add to this list</h2>
<div className="search">
<Icon name="search" />
<label className="sr-only" htmlFor="add-work-q">Search the catalogue and your library</label>
<input id="add-work-q" className="input" placeholder="Search by title" value={q} onChange={(e) => setQ(e.target.value)} autoComplete="off" />
</div>
{results.error && <LoadError error={results.error} retry={() => void results.refetch()} />}
{dq && results.data && (
results.data.length === 0 ? (
<p className="empty-line">No matches. Quick add it to your library first, then add it here.</p>
) : (
<ul className="results">
{results.data.map((w) => (
<li key={w.id}>
<Cover url={w.cover_url} mediaType={w.media_type} draft={false} size="small" />
<span>
<span className="t">{w.title}</span>
<span className="m"> {mediaTypeLabel(w.media_type)}{w.year ? ` · ${w.year}` : ""}</span>
</span>
{have.has(w.id) ? (
<span className="hint">Added</span>
) : (
<button type="button" className="btn small" onClick={() => add.mutate(w.id)} disabled={add.isPending} aria-label={`Add ${w.title}`}>Add</button>
)}
</li>
))}
</ul>
)
)}
{error && <p className="error-text" role="alert">{error}</p>}
</section>
);
}
+41
View File
@@ -573,3 +573,44 @@ a.log-row:hover { background: var(--surface-2); }
.log-row.skeleton .bar + .bar { width: 35%; margin-top: 6px; height: 8px; }
.log-row.skeleton .cover { background: var(--surface-2); }
.more-btn { margin-top: 18px; }
/* ---------- Lists ---------- */
.pane-title { font-size: 1.05rem; font-weight: 650; letter-spacing: -0.01em; }
.new-list { display: grid; gap: 8px; }
.new-list-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.kind-chips { display: flex; gap: 5px; flex-wrap: wrap; }
.chip[aria-checked="true"] { background: var(--surface-3); color: #fff; border-color: var(--surface-3); }
.list-row { grid-template-columns: 10px minmax(0, 1fr) auto; }
.kind-mark { width: 8px; height: 8px; border-radius: 2px; background: var(--dash); }
.kind-mark.k-grouping { background: var(--rating); border-radius: 50%; }
.kind-mark.k-thread { background: transparent; border: 1.5px dashed var(--pill-text); border-radius: 50%; }
.list-head { display: grid; gap: 8px; margin-bottom: 18px; }
.title-input, .desc-input {
background: transparent; border: 1px solid transparent; border-radius: var(--r-control); color: var(--text);
margin: 0 -8px; padding: 2px 8px; width: calc(100% + 16px); transition: border-color 0.15s, background 0.15s;
}
.title-input { font-size: 1.45rem; font-weight: 650; letter-spacing: -0.012em; line-height: 1.25; }
.desc-input { resize: vertical; color: var(--text-muted); font-size: 0.88rem; line-height: 1.5; }
.desc-input::placeholder { color: var(--text-faint); }
.title-input:hover, .desc-input:hover { border-color: var(--surface-3); }
.title-input:focus-visible, .desc-input:focus-visible { outline: none; border-color: var(--focus); background: #211d40; }
.list-head .det-controls .m { color: var(--text-muted); font-size: 0.82rem; }
.items { list-style: none; display: grid; gap: 2px; }
.item { display: grid; grid-template-columns: 22px 28px minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 6px 8px; margin: 0 -8px; border-radius: var(--r-control); }
.item:hover { background: var(--surface-2); }
.item .pos { color: var(--text-faint); font-size: 0.8rem; text-align: right; }
.item-main { min-width: 0; display: grid; }
.item-main .t { font-size: 0.88rem; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item-main .m { font-size: 0.76rem; color: var(--text-muted); }
.item-acts { display: flex; gap: 2px; opacity: 0.55; transition: opacity 0.15s; }
.item:hover .item-acts, .item-acts:focus-within { opacity: 1; }
@media (hover: none) { .item-acts { opacity: 1; } }
.icon-btn { width: 28px; height: 28px; padding: 0; justify-content: center; }
.list-empty { padding: 6px 0 4px; }
.add-work { margin-top: 18px; }
.add-work .results { list-style: none; margin-top: 10px; display: grid; gap: 2px; }
.add-work .results li { display: grid; grid-template-columns: 28px minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 4px 6px; border-radius: 6px; font-size: 0.84rem; }
.add-work .results li:hover { background: var(--surface-3); }
.add-work .results .m { color: var(--text-muted); font-size: 0.76rem; }
.add-work .search { max-width: 420px; }
.list-foot { margin-top: 28px; }