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:
co-authored by
Claude Opus 5.5
parent
ec39491f47
commit
bbd04a5577
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user