diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index dc51c3c..3be5f8b 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -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() {
{(p) => }
+ {() => }
+ {(p) => }
{(p) => }
{(p) => }
diff --git a/apps/web/src/routes/lists.tsx b/apps/web/src/routes/lists.tsx
new file mode 100644
index 0000000..c91595b
--- /dev/null
+++ b/apps/web/src/routes/lists.tsx
@@ -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 (
+
+
+
+
Lists
+
+
+ {lists.error && lists.data && void lists.refetch()} stale className="in-list" />}
+ {lists.isPending ? (
+ Loading…
+ ) : lists.error && !lists.data ? (
+ void lists.refetch()} />
+ ) : lists.data!.length === 0 ? (
+
+
No lists yet. Name one above. A list can hold books, games, shows and albums side by side.
+
+ ) : (
+
+ {lists.data!.map((l) => (
+ -
+
+
+
+ {l.name}
+ {kindLabel(l.kind)}{l.description ? ` · ${l.description}` : ""}
+
+
+
+
+ ))}
+
+ )}
+
+
+ {selectedId ? (
+
+ ) : (
+ Pick a listOr make a new one: a reading order, a box set to rate as a whole, or a thread across media.
+ )}
+
+
+ );
+}
+
+function NewList() {
+ const qc = useQueryClient();
+ const [, navigate] = useLocation();
+ const [kind, setKind] = useState("list");
+ const [error, setError] = useState(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) {
+ 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 (
+
+ );
+}
+
+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(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[1]) => api.patchList(id, b),
+ onMutate: async (b) => {
+ await qc.cancelQueries({ queryKey: key });
+ const prev = qc.getQueryData(key);
+ if (prev) qc.setQueryData(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(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(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 ;
+ if (!list.data) {
+ return (
+
+ Lists
+ void list.refetch()} />
+
+ );
+ }
+ const l = list.data;
+ const n = l.items.length;
+
+ return (
+
+ Lists
+ {list.error && void list.refetch()} stale />}
+
+ {said}
+ {error && {error}
}
+
+ {n === 0 ? (
+ Nothing here yet. Search below to add something.
+ ) : (
+
+ {l.items.map((it, i) => (
+ -
+ {i + 1}
+
+
+ {it.work.title}
+ {mediaTypeLabel(it.work.mediaType)}{it.work.year ? ` · ${it.work.year}` : ""}
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ i.workId))} onAdded={refresh} />
+
+
+ {confirming ? (
+
+ Delete “{l.name}”? The things in it stay in your library.
+
+
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function AddWork({ listId, have, onAdded }: { listId: string; have: Set; onAdded: () => void }) {
+ const [q, setQ] = useState("");
+ const dq = useDebounced(q.trim());
+ const [error, setError] = useState(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 (
+
+ Add to this list
+
+
+
+ setQ(e.target.value)} autoComplete="off" />
+
+ {results.error && void results.refetch()} />}
+ {dq && results.data && (
+ results.data.length === 0 ? (
+ No matches. Quick add it to your library first, then add it here.
+ ) : (
+
+ {results.data.map((w) => (
+ -
+
+
+ {w.title}
+ {mediaTypeLabel(w.media_type)}{w.year ? ` · ${w.year}` : ""}
+
+ {have.has(w.id) ? (
+ Added
+ ) : (
+
+ )}
+
+ ))}
+
+ )
+ )}
+ {error && {error}
}
+
+ );
+}
+
diff --git a/apps/web/src/theme.css b/apps/web/src/theme.css
index b27e65b..42bc704 100644
--- a/apps/web/src/theme.css
+++ b/apps/web/src/theme.css
@@ -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; }