From bbd04a557713bb424a05f1dea077088f9162f179 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 22:20:10 +0100 Subject: [PATCH] feat(web): lists, groupings and threads with search-to-add, reorder and grouping rating Co-Authored-By: Claude Opus 5.5 --- apps/web/src/main.tsx | 3 + apps/web/src/routes/lists.tsx | 316 ++++++++++++++++++++++++++++++++++ apps/web/src/theme.css | 41 +++++ 3 files changed, 360 insertions(+) create mode 100644 apps/web/src/routes/lists.tsx 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) => ( +
  • + +
  • + ))} +
+ )} +
+
+ {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 ( +
+ +
+ + +
+
+
+ {KINDS.map((k) => ( + + ))} +
+ +
+

{KINDS.find((k) => k.kind === kind)!.hint}

+ {error &&

{error}

} +
+ ); +} + +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 />} +
+ + { + 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()} + /> +
+ + + {n === 1 ? "1 thing" : `${n} things`} + {l.kind === "grouping" && patch.mutate({ rating: v })} />} +
+ +