diff --git a/apps/web/src/components/Cover.tsx b/apps/web/src/components/Cover.tsx
new file mode 100644
index 0000000..f477423
--- /dev/null
+++ b/apps/web/src/components/Cover.tsx
@@ -0,0 +1,18 @@
+import { useState } from "react";
+import { mediaColour } from "../format.ts";
+
+/** A cover image, the media-type gradient when there is none, or the striped draft placeholder. */
+export function Cover({ url, mediaType, draft, size }: { url: string; mediaType: string; draft: boolean; size?: "large" | "small" }) {
+ const [failed, setFailed] = useState(false);
+ const cls = `cover${size ? ` ${size}` : ""}`;
+ if (url && !failed) {
+ return (
+
+
setFailed(true)} />
+
+ );
+ }
+ if (draft) return
;
+ const c = mediaColour(mediaType);
+ return
;
+}
diff --git a/apps/web/src/components/DetailBoxes.tsx b/apps/web/src/components/DetailBoxes.tsx
new file mode 100644
index 0000000..7dd1e66
--- /dev/null
+++ b/apps/web/src/components/DetailBoxes.tsx
@@ -0,0 +1,520 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useState, type FormEvent, type ReactNode } from "react";
+import { Link } from "wouter";
+import { ApiError, api, type Attachment, type Copy, type CustomFieldDef, type Engagement, type EntryView } from "../api.ts";
+import {
+ actorConnectionId,
+ describeRating,
+ engagementNouns,
+ formatDateRange,
+ formatPartialDate,
+ formatRating,
+ ordinal,
+ parsePartialDate,
+ rollupLabel,
+ statusLabel,
+ timeAgo,
+ type PartialDate,
+} from "../format.ts";
+import { useEntryPatch, useSources } from "../hooks.ts";
+import { useRatingScale } from "../settings.ts";
+import { Icon } from "./Icon.tsx";
+import { StarRating } from "./StarRating.tsx";
+
+function Box({ title, id, action, wide, className, children }: { title: string; id: string; action?: ReactNode; wide?: boolean; className?: string; children: ReactNode }) {
+ return (
+
+
+
{title}
+ {action}
+
+ {children}
+
+ );
+}
+
+const errText = (e: unknown) => (e instanceof ApiError ? e.message : "That didn't save. Try again.");
+
+// ---------------------------------------------------------------------------------------------
+// Reads / plays
+// ---------------------------------------------------------------------------------------------
+
+export function EngagementsBox({
+ entry,
+ nouns,
+ engagements,
+ loading,
+}: {
+ entry: EntryView;
+ nouns: ReturnType;
+ engagements: Engagement[] | undefined;
+ loading: boolean;
+}) {
+ const qc = useQueryClient();
+ const [adding, setAdding] = useState(false);
+ const [formError, setFormError] = useState(null);
+ const refresh = () => {
+ void qc.invalidateQueries({ queryKey: ["engagements", entry.id] });
+ void qc.invalidateQueries({ queryKey: ["history", entry.id] });
+ };
+ const add = useMutation({
+ mutationFn: (b: { startedAt: PartialDate | null; finishedAt: PartialDate | null }) => api.addEngagement(entry.id, b),
+ onSuccess: () => {
+ setAdding(false);
+ refresh();
+ },
+ onError: (e) => setFormError(errText(e)),
+ });
+ const remove = useMutation({ mutationFn: api.deleteEngagement, onSuccess: refresh });
+
+ function submit(e: FormEvent) {
+ e.preventDefault();
+ const f = new FormData(e.currentTarget);
+ const read = (k: string) => {
+ const raw = String(f.get(k) ?? "").trim();
+ if (!raw) return { ok: true as const, v: null };
+ const pd = parsePartialDate(raw);
+ return pd ? { ok: true as const, v: pd } : { ok: false as const, v: null };
+ };
+ const s = read("startedAt");
+ const d = read("finishedAt");
+ if (!s.ok || !d.ok) return setFormError("Use a year, a month or a day: 2025, 2025-03 or 2025-03-14.");
+ setFormError(null);
+ add.mutate({ startedAt: s.v, finishedAt: d.v });
+ }
+
+ // Oldest is the 1st; show newest first, like the mockup.
+ const ordered = [...(engagements ?? [])].sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1));
+ const rows = ordered.map((g, i) => ({ g, n: i + 1 })).reverse();
+
+ return (
+ setAdding(true)}>
+ {nouns.log}
+
+ )
+ }
+ >
+ {loading ? null : rows.length === 0 && !adding ? (
+ Nothing logged yet.
+ ) : (
+ rows.map(({ g, n }) => (
+
+ {ordinal(n)} {nouns.one}
+
+ {formatDateRange(g.startedAt, g.finishedAt)}
+ remove.mutate(g.id)}
+ >
+
+
+
+
+ ))
+ )}
+ {adding && (
+
+ )}
+
+ );
+}
+
+// ---------------------------------------------------------------------------------------------
+// Your copy
+// ---------------------------------------------------------------------------------------------
+
+function formatPrice(c: Copy): string {
+ if (c.priceCents === null) return "";
+ const amount = c.priceCents / 100;
+ if (!c.currency) return amount.toFixed(2);
+ try {
+ return new Intl.NumberFormat(undefined, { style: "currency", currency: c.currency, minimumFractionDigits: c.priceCents % 100 ? 2 : 0 }).format(amount);
+ } catch {
+ return `${amount.toFixed(2)} ${c.currency}`;
+ }
+}
+
+export function CopyBox({ entry }: { entry: EntryView }) {
+ const qc = useQueryClient();
+ const copies = useQuery({ queryKey: ["copies", entry.workId], queryFn: () => api.copies(entry.workId) });
+ const [adding, setAdding] = useState(false);
+ const [formError, setFormError] = useState(null);
+ const add = useMutation({
+ mutationFn: api.addCopy,
+ onSuccess: () => {
+ setAdding(false);
+ void qc.invalidateQueries({ queryKey: ["copies", entry.workId] });
+ },
+ onError: (e) => setFormError(errText(e)),
+ });
+
+ function submit(e: FormEvent) {
+ e.preventDefault();
+ const f = new FormData(e.currentTarget);
+ const s = (k: string) => String(f.get(k) ?? "").trim() || null;
+ const when = s("acquiredAt");
+ const acquiredAt = when ? parsePartialDate(when) : null;
+ if (when && !acquiredAt) return setFormError("Use a year, a month or a day for when: 2025, 2025-02 or 2025-02-14.");
+ const price = s("price");
+ const priceNum = price ? Number(price.replace(",", ".")) : null;
+ if (price && (priceNum === null || Number.isNaN(priceNum))) return setFormError("Price should be a number, like 14 or 14.99.");
+ const currency = s("currency")?.toUpperCase() ?? null;
+ if (currency && !/^[A-Z]{3}$/.test(currency)) return setFormError("Currency is a three-letter code, like GBP or EUR.");
+ setFormError(null);
+ add.mutate({
+ workId: entry.workId,
+ format: s("format"),
+ acquiredFrom: s("acquiredFrom"),
+ acquiredAt,
+ priceCents: priceNum === null ? null : Math.round(priceNum * 100),
+ currency,
+ });
+ }
+
+ const list = copies.data ?? [];
+ return (
+ 0 && (
+ setAdding(true)}>
+ Another
+
+ )
+ }
+ >
+ {copies.isPending ? null : list.length === 0 && !adding ? (
+
+ You haven't recorded a copy.
+ setAdding(true)}>Add your copy
+
+ ) : (
+ list.map((c) => {
+ const when = [formatPartialDate(c.acquiredAt), formatPrice(c)].filter(Boolean).join(" · ");
+ return (
+
+ {c.acquiredFrom &&
Bought {c.acquiredFrom}
}
+ {when &&
When / price {when}
}
+ {c.format &&
Format {c.format}
}
+ {!c.acquiredFrom && !when && !c.format &&
Owned No details yet
}
+
+ );
+ })
+ )}
+ {adding && (
+
+ )}
+
+ );
+}
+
+// ---------------------------------------------------------------------------------------------
+// Attachments
+// ---------------------------------------------------------------------------------------------
+
+// The API has no "list attachments of an entry" route yet, so this shows what was uploaded in this session.
+const uploaded = new Map();
+
+export function AttachmentsBox({ entry }: { entry: EntryView }) {
+ const [items, setItems] = useState(() => uploaded.get(entry.id) ?? []);
+ const [error, setError] = useState(null);
+ const upload = useMutation({
+ mutationFn: (file: File) => api.upload(file, entry.id),
+ onSuccess: (a) => {
+ const next = [...items, a];
+ uploaded.set(entry.id, next);
+ setItems(next);
+ setError(null);
+ },
+ onError: (e) => setError(e instanceof ApiError && e.code === "validation" ? "That file type isn't supported. Use an image, a PDF or a text file." : errText(e)),
+ });
+ return (
+
+
+ {items.map((a) => {
+ const url = `/api/v1/attachments/${a.id}`;
+ const image = a.mime.startsWith("image/");
+ return (
+
+ {!image && }
+
+ );
+ })}
+
+ Upload an attachment
+ {upload.isPending ? "…" : }
+ {
+ const f = e.currentTarget.files?.[0];
+ if (f) upload.mutate(f);
+ e.currentTarget.value = "";
+ }}
+ />
+
+
+ {error && {error}
}
+ {items.length === 0 && !error && Screenshots, photos of your copy, receipts.
}
+
+ );
+}
+
+// ---------------------------------------------------------------------------------------------
+// Linked sources
+// ---------------------------------------------------------------------------------------------
+
+export function SourcesBox({ entry, engagements }: { entry: EntryView; engagements: Engagement[] }) {
+ const sources = useSources();
+ // A source is linked when it has written any field of the entry or its reads.
+ const ids = new Set();
+ for (const fs of [entry.fieldSources, ...engagements.map((g) => g.fieldSources)]) {
+ for (const actor of Object.values(fs ?? {})) {
+ const id = actorConnectionId(actor);
+ if (id) ids.add(id);
+ }
+ }
+ const linked = (sources.data ?? []).filter((s) => ids.has(s.id));
+ return (
+
+ {linked.length === 0 ? (
+ No source syncs this entry. Everything here is yours.
+ ) : (
+ linked.map((s) => (
+
+
+ {s.name} · {s.status !== "active" ? "paused" : s.lastRunAt ? `synced ${timeAgo(s.lastRunAt)}` : "not synced yet"}
+
+ ))
+ )}
+
+ );
+}
+
+// ---------------------------------------------------------------------------------------------
+// Custom fields
+// ---------------------------------------------------------------------------------------------
+
+export function CustomFieldsBox({ entry, type }: { entry: EntryView; type: string }) {
+ const defs = useQuery({ queryKey: ["custom-fields", type], queryFn: () => api.customFields(type), staleTime: 60_000 });
+ const patch = useEntryPatch(entry.id);
+ if (!defs.data?.length) return null;
+ const save = (def: CustomFieldDef, value: unknown) => {
+ const next = { ...entry.customFields };
+ if (value === null || value === "" || (Array.isArray(value) && value.length === 0)) delete next[def.id];
+ else next[def.id] = value;
+ if (JSON.stringify(next) !== JSON.stringify(entry.customFields)) patch.mutate({ customFields: next });
+ };
+ return (
+
+
+ {defs.data.map((d) => (
+ save(d, v)} />
+ ))}
+
+ {patch.error && {errText(patch.error)}
}
+
+ );
+}
+
+function CustomField({ def, value, onSave }: { def: CustomFieldDef; value: unknown; onSave: (v: unknown) => void }) {
+ const scale = useRatingScale();
+ const str = value === undefined || value === null ? "" : String(value);
+ const text = (multiline: boolean, inputType = "text") => (
+
+ {def.name}
+ {multiline ? (
+
+ );
+ switch (def.type) {
+ case "long_text":
+ return text(true);
+ case "number":
+ return text(false, "number");
+ case "url":
+ return text(false, "url");
+ case "boolean":
+ return (
+
+ onSave(e.target.checked ? true : null)} />
+ {def.name}
+
+ );
+ case "single_select":
+ return (
+
+ {def.name}
+ onSave(e.target.value || null)}>
+ —
+ {def.options.map((o) => {o} )}
+
+
+ );
+ case "multi_select": {
+ const cur = Array.isArray(value) ? (value as string[]) : [];
+ return (
+
+ {def.name}
+
+ {def.options.map((o) => (
+
+ onSave(e.target.checked ? [...cur, o] : cur.filter((x) => x !== o))} />
+ {o}
+
+ ))}
+
+
+ );
+ }
+ case "rating":
+ return (
+
+ {def.name}
+
+
+ );
+ default:
+ return text(false);
+ }
+}
+
+// ---------------------------------------------------------------------------------------------
+// Children (seasons, episodes, volumes)
+// ---------------------------------------------------------------------------------------------
+
+export function ChildrenBox({ entry, type }: { entry: EntryView; type: string }) {
+ const scale = useRatingScale();
+ const children = useQuery({
+ queryKey: ["entries", { parentId: entry.workId }],
+ queryFn: () => api.entries({ parentId: entry.workId, limit: 200 }),
+ });
+ const label = rollupLabel(type, entry.childSummary ? { ...entry.childSummary, completed: 0 } : null) ?? "Parts";
+ const title = label.replace(/^\d+\s+/, "");
+ return (
+
+
+ {(children.data?.items ?? []).map((c) => (
+
+
+
+ {c.work.title}
+ {statusLabel(c.status, c.work.mediaType)}
+
+ {c.rating !== null ? (
+ {formatRating(c.rating, scale)}
+ ) : (
+ Not rated
+ )}
+
+
+ ))}
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------------------------
+// Review
+// ---------------------------------------------------------------------------------------------
+
+export function ReviewBox({ entry }: { entry: EntryView }) {
+ const patch = useEntryPatch(entry.id);
+ const [saved, setSaved] = useState(false);
+ const save = (v: string) => {
+ const next = v.trim() === "" ? null : v;
+ if (next === entry.review) return;
+ patch.mutate({ review: next }, { onSuccess: () => setSaved(true) });
+ };
+ return (
+ {patch.isPending ? "Saving…" : saved ? "Saved" : ""}}
+ >
+ Your review (Markdown)
+
+ );
+}
diff --git a/apps/web/src/components/EntryDetail.tsx b/apps/web/src/components/EntryDetail.tsx
new file mode 100644
index 0000000..d91294b
--- /dev/null
+++ b/apps/web/src/components/EntryDetail.tsx
@@ -0,0 +1,189 @@
+import { useQuery } from "@tanstack/react-query";
+import { useRef, useState } from "react";
+import { Link, useLocation } from "wouter";
+import { ApiError, api, type EntryView } from "../api.ts";
+import { engagementNouns, formatPartialDate, mediaTypeLabel, STATUSES, statusLabel } from "../format.ts";
+import { changesFor, useEntryPatch, usePendingChanges, useSources } from "../hooks.ts";
+import { useRatingScale } from "../settings.ts";
+import { Cover } from "./Cover.tsx";
+import { AttachmentsBox, ChildrenBox, CopyBox, CustomFieldsBox, EngagementsBox, ReviewBox, SourcesBox } from "./DetailBoxes.tsx";
+import { HistoryDrawer } from "./HistoryDrawer.tsx";
+import { Icon } from "./Icon.tsx";
+import { LinkToCatalogue } from "./LinkToCatalogue.tsx";
+import { StarRating } from "./StarRating.tsx";
+
+const TIMES = ["", "once", "twice", "three times"];
+
+export function EntryDetail({ id }: { id: string }) {
+ const entry = useQuery({ queryKey: ["entry", id], queryFn: () => api.entry(id) });
+ if (entry.isPending) return
;
+ if (entry.error) {
+ const gone = entry.error instanceof ApiError && entry.error.status === 404;
+ return (
+
+
+ {gone ? "This entry isn't here" : "Couldn't load this entry"}
+ {gone ? "It may have been deleted or moved. Pick another from your library." : entry.error.message}
+
+
+ );
+ }
+ return ;
+}
+
+function Detail({ entry }: { entry: EntryView }) {
+ const scale = useRatingScale();
+ const patch = useEntryPatch(entry.id);
+ const [, navigate] = useLocation();
+ const [historyOpen, setHistoryOpen] = useState(false);
+ const historyBtn = useRef(null);
+ const type = entry.mediaTypeOverride ?? entry.work.mediaType;
+ const nouns = engagementNouns(type);
+
+ const engagements = useQuery({ queryKey: ["engagements", entry.id], queryFn: () => api.engagements(entry.id) });
+ const changes = usePendingChanges();
+ const sources = useSources();
+ const engagementIds = new Set((engagements.data ?? []).map((g) => g.id));
+ const pending = changesFor(changes.data?.items, entry.id, engagementIds);
+
+ // "Book · Read twice · last finished 14 Sep 2026"
+ const finished = (engagements.data ?? []).filter((g) => g.finishedAt && g.finishedAt.precision !== "unknown");
+ const meta = [mediaTypeLabel(type)];
+ if (entry.work.year) meta.push(String(entry.work.year));
+ if (finished.length) {
+ const past = nouns.one === "read" ? "Read" : nouns.one === "play" ? "Played" : nouns.one === "watch" ? "Watched" : nouns.one === "listen" ? "Listened" : "Finished";
+ meta.push(`${past} ${TIMES[finished.length] ?? `${finished.length} times`}`);
+ const last = finished.map((g) => g.finishedAt!).sort((a, b) => (a.value < b.value ? 1 : -1))[0]!;
+ meta.push(`last finished ${formatPartialDate(last)}`);
+ }
+
+ return (
+
+ Library
+
+
+
+
+ {entry.work.title}
+ {entry.draft && Draft }
+
+
{meta.join(" · ")}
+
+
patch.mutate({ rating })} />
+
+ Status
+ patch.mutate({ status: e.target.value as EntryView["status"] })}>
+ {STATUSES.map((s) => (
+ {statusLabel(s, type)}
+ ))}
+
+
+
+ setHistoryOpen((o) => !o)}
+ >
+ History
+
+
+
+
patch.mutate({ tags })} />
+ {patch.error && {patch.error.message}
}
+
+
+
+ {pending.length > 0 && (
+
+
+ {whoChanged(pending, sources.data)} changed this {mediaTypeLabel(type).toLowerCase()}. {" "}
+ {pending.length === 1 ? `On ${shortDate(pending[0]!.createdAt)}.` : `${pending.length} changes are waiting for you.`} Your rating,{" "}
+ {nouns.box.toLowerCase()} and review here are unchanged.
+
+
navigate(`/changes?entry=${entry.id}`)}>See what changed
+
+ )}
+
+ {entry.draft && }
+
+
+
+
+
+
+
+ {entry.childSummary &&
}
+
+
+
+ {historyOpen && (
+ {
+ setHistoryOpen(false);
+ historyBtn.current?.focus();
+ }}
+ />
+ )}
+
+ );
+}
+
+function whoChanged(pending: { connectionId: string | null }[], sources: { id: string; name: string }[] | undefined): string {
+ const ids = new Set(pending.map((p) => p.connectionId));
+ if (ids.size === 1) {
+ const name = sources?.find((s) => s.id === [...ids][0])?.name;
+ if (name) return name;
+ }
+ return ids.size > 1 ? "Your sources" : "A source";
+}
+
+const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
+function shortDate(iso: string): string {
+ const d = new Date(iso);
+ return `${d.getDate()} ${MONTHS[d.getMonth()]}`;
+}
+
+function Tags({ entry, onChange }: { entry: EntryView; onChange: (tags: string[]) => void }) {
+ const [draft, setDraft] = useState("");
+ function add() {
+ const t = draft.trim();
+ if (!t) return;
+ if (!entry.tags.some((x) => x.toLowerCase() === t.toLowerCase())) onChange([...entry.tags, t]);
+ setDraft("");
+ }
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/HistoryDrawer.tsx b/apps/web/src/components/HistoryDrawer.tsx
new file mode 100644
index 0000000..e69bf96
--- /dev/null
+++ b/apps/web/src/components/HistoryDrawer.tsx
@@ -0,0 +1,115 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useEffect, useRef } from "react";
+import { api, type EntryView, type HistoryRecord } from "../api.ts";
+import { actorConnectionId, formatPartialDate, formatRating, statusLabel, timeAgo, type PartialDate, type Status } from "../format.ts";
+import { useSources } from "../hooks.ts";
+import { useRatingScale } from "../settings.ts";
+import { Icon } from "./Icon.tsx";
+
+const LABELS: Record = {
+ status: "Status",
+ rating: "Rating",
+ review: "Review",
+ privateNotes: "Private notes",
+ favorite: "Favourite",
+ tags: "Tags",
+ customFields: "Details",
+ visibility: "Visibility",
+ overrides: "Title and cover",
+ mediaTypeOverride: "Media type",
+ workId: "Catalogue link",
+ deletedAt: "Removed",
+};
+const SKIP = new Set(["id", "userId", "fieldSources", "createdAt", "updatedAt"]);
+
+/** History of an entry, newest first, with "Restore" to put back what a change replaced. */
+export function HistoryDrawer({ entry, onClose }: { entry: EntryView; onClose: () => void }) {
+ const qc = useQueryClient();
+ const scale = useRatingScale();
+ const sources = useSources();
+ const closeBtn = useRef(null);
+ const history = useQuery({ queryKey: ["history", entry.id], queryFn: () => api.history(entry.id) });
+ const restore = useMutation({
+ mutationFn: api.restore,
+ onSuccess: () => {
+ for (const key of [["entry", entry.id], ["history", entry.id], ["engagements", entry.id], ["entries"]]) void qc.invalidateQueries({ queryKey: key });
+ },
+ });
+
+ useEffect(() => {
+ closeBtn.current?.focus();
+ const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [onClose]);
+
+ const who = (actor: string) => {
+ if (actor === "user") return "You";
+ if (actor.startsWith("token:")) return `App token “${actor.slice(6)}”`;
+ const id = actorConnectionId(actor);
+ return sources.data?.find((s) => s.id === id)?.name ?? actor.split(":")[1] ?? "A source";
+ };
+ const show = (field: string, v: unknown): string => {
+ if (v === null || v === undefined || v === "") return "empty";
+ if (field === "rating") return formatRating(v as number, scale);
+ if (field === "status") return statusLabel(v as Status, entry.work.mediaType);
+ if (Array.isArray(v)) return v.length ? v.join(", ") : "none";
+ if (typeof v === "object" && "precision" in (v as object)) return formatPartialDate(v as PartialDate) || "unknown";
+ if (typeof v === "object") return Object.keys(v as object).length ? "edited" : "empty";
+ if (typeof v === "boolean") return v ? "yes" : "no";
+ const s = String(v);
+ return s.length > 80 ? `${s.slice(0, 80)}…` : s;
+ };
+ const diffs = (h: HistoryRecord) =>
+ [...new Set([...Object.keys(h.before ?? {}), ...Object.keys(h.after ?? {})])]
+ .filter((k) => !SKIP.has(k) && JSON.stringify(h.before?.[k]) !== JSON.stringify(h.after?.[k]))
+ .map((k) => ({ k, before: show(k, h.before?.[k]), after: show(k, h.after?.[k]) }));
+
+ return (
+
+
+ {history.isPending ? null : history.error ? (
+ {history.error.message}
+ ) : (
+
+ {history.data.map((h) => {
+ const d = diffs(h);
+ const created = Object.keys(h.before ?? {}).length === 0;
+ return (
+
+
+ {who(h.actor)} · {timeAgo(h.createdAt)}
+ {!created && d.length > 0 && (
+ restore.mutate(h.id)}>
+ Restore
+
+ )}
+
+ {created ? (
+ Added to your library.
+ ) : d.length === 0 ? (
+ No visible change.
+ ) : (
+
+ {d.map(({ k, before, after }) => (
+
+
{LABELS[k] ?? k}
+ {before} → {after}
+
+ ))}
+
+ )}
+
+ );
+ })}
+
+ )}
+ {restore.error && {restore.error.message}
}
+
+ );
+}
diff --git a/apps/web/src/components/Icon.tsx b/apps/web/src/components/Icon.tsx
new file mode 100644
index 0000000..4473008
--- /dev/null
+++ b/apps/web/src/components/Icon.tsx
@@ -0,0 +1,21 @@
+/** A small authored icon set: 24px grid, 1.75 stroke, round caps. */
+const PATHS = {
+ plus: "M12 5v14M5 12h14",
+ search: "M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM20 20l-4-4",
+ x: "M6 6l12 12M18 6L6 18",
+ history: "M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5M12 7v5l3 2",
+ back: "M15 18l-6-6 6-6",
+ upload: "M12 16V4M7 9l5-5 5 5M5 20h14",
+ link: "M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1",
+ file: "M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8zM14 3v5h5",
+} as const;
+
+export type IconName = keyof typeof PATHS;
+
+export function Icon({ name }: { name: IconName }) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/components/LinkToCatalogue.tsx b/apps/web/src/components/LinkToCatalogue.tsx
new file mode 100644
index 0000000..f9ef208
--- /dev/null
+++ b/apps/web/src/components/LinkToCatalogue.tsx
@@ -0,0 +1,102 @@
+import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useState, type ReactNode } from "react";
+import { useLocation } from "wouter";
+import { api, type EntryView, type Work } from "../api.ts";
+import { mediaTypeLabel } from "../format.ts";
+import { useDebounced } from "../hooks.ts";
+import { Cover } from "./Cover.tsx";
+import { Icon } from "./Icon.tsx";
+
+/** Drafts only: suggested matches and a catalogue search. Linking keeps the user's rating, reads and review. */
+export function LinkToCatalogue({ entry }: { entry: EntryView }) {
+ const qc = useQueryClient();
+ const [, navigate] = useLocation();
+ const [q, setQ] = useState("");
+ const term = useDebounced(q.trim());
+ const suggestions = useQuery({ queryKey: ["suggestions", entry.workId], queryFn: () => api.suggestions(entry.workId) });
+ const candidates = useQueries({
+ queries: (suggestions.data ?? []).slice(0, 5).map((s) => ({ queryKey: ["work", s.candidateWorkId], queryFn: () => api.work(s.candidateWorkId), staleTime: 300_000 })),
+ });
+ const search = useQuery({
+ queryKey: ["works-search", term],
+ queryFn: () => api.searchWorks(term),
+ enabled: term.length >= 2,
+ });
+
+ const done = (newId?: string) => {
+ void qc.invalidateQueries({ queryKey: ["entries"] });
+ void qc.invalidateQueries({ queryKey: ["entry", entry.id] });
+ void qc.invalidateQueries({ queryKey: ["suggestions", entry.workId] });
+ if (newId && newId !== entry.id) navigate(`/entries/${newId}`, { replace: true });
+ };
+ const move = useMutation({ mutationFn: (workId: string) => api.moveEntry(entry.id, workId), onSuccess: (e) => done(e.id) });
+ const accept = useMutation({ mutationFn: api.acceptSuggestion, onSuccess: () => done() });
+ const reject = useMutation({ mutationFn: api.rejectSuggestion, onSuccess: () => done() });
+ const busy = move.isPending || accept.isPending || reject.isPending;
+ const error = move.error ?? accept.error ?? reject.error;
+
+ const row = (w: Work, actions: ReactNode) => (
+
+
+
+
{w.title}
+
{[mediaTypeLabel(w.media_type), w.year, w.creators?.[0]?.name].filter(Boolean).join(" · ")}
+
+ {actions}
+
+ );
+
+ const results = (search.data ?? []).filter((w) => w.id !== entry.workId);
+ return (
+
+
+
Link to catalogue
+
+ This is a draft. Link it to a catalogue entry to fill in the cover and details. Your rating, reads and review stay as they are.
+
+ {candidates.length > 0 && (
+ <>
+ Suggested
+
+ {suggestions.data!.slice(0, 5).map((s, i) => {
+ const w = candidates[i]?.data;
+ if (!w) return null;
+ return row(
+ w,
+ <>
+ accept.mutate(s.id)}>Link
+ reject.mutate(s.id)}>Not this
+ >,
+ );
+ })}
+
+ >
+ )}
+
+
+
+ Search the catalogue
+ setQ(e.target.value)}
+ />
+
+ {term.length >= 2 && !search.isPending && (
+ results.length === 0 ? (
+ Nothing in the catalogue matches “{term}”. Connect a source to bring in more.
+ ) : (
+
+ {results.map((w) =>
+ row(w, move.mutate(w.id)}>Link ),
+ )}
+
+ )
+ )}
+ {error && {error.message}
}
+
+ );
+}
diff --git a/apps/web/src/components/Shell.tsx b/apps/web/src/components/Shell.tsx
new file mode 100644
index 0000000..5e9a60d
--- /dev/null
+++ b/apps/web/src/components/Shell.tsx
@@ -0,0 +1,75 @@
+import { useQueryClient } from "@tanstack/react-query";
+import type { ReactNode } from "react";
+import { Link, useLocation } from "wouter";
+import { api } from "../api.ts";
+import { useMe, usePendingChanges, useSources } from "../hooks.ts";
+
+function NavLink({ href, active, children, className }: { href: string; active: boolean; children: ReactNode; className?: string }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** Sidebar navigation plus the main area. Stacks into a top bar on narrow screens. */
+export function Shell({ children }: { children: ReactNode }) {
+ const [path, navigate] = useLocation();
+ const qc = useQueryClient();
+ const me = useMe();
+ const changes = usePendingChanges();
+ const sources = useSources();
+ const pending = changes.data?.items.length ?? 0;
+ const at = (p: string) => path === p || path.startsWith(`${p}/`);
+
+ async function signOut() {
+ try {
+ await api.logout();
+ } finally {
+ qc.clear();
+ navigate("/login", { replace: true });
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/StarRating.tsx b/apps/web/src/components/StarRating.tsx
new file mode 100644
index 0000000..a104d9d
--- /dev/null
+++ b/apps/web/src/components/StarRating.tsx
@@ -0,0 +1,148 @@
+import { useEffect, useId, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
+import { describeRating, ratingStep, snapRating, type RatingScale } from "../format.ts";
+
+const STAR = "M12 2.6l2.9 5.9 6.5.9-4.7 4.6 1.1 6.5L12 17.4l-5.8 3.1 1.1-6.5L2.6 9.4l6.5-.9z";
+
+/**
+ * Editable rating in the user's scale. Stars are a slider: arrows step (half a star on "5half"),
+ * Home/End jump, Delete clears. The 10 and 100 point scales use a number field.
+ */
+export function StarRating({
+ value,
+ scale,
+ label,
+ onChange,
+}: {
+ value: number | null;
+ scale: RatingScale;
+ label: string;
+ onChange: (v: number | null) => void;
+}) {
+ const [hover, setHover] = useState(null);
+ // Arrow keys step a local value; it is saved once the user pauses (or leaves), not on every step.
+ const [stepping, setStepping] = useState<{ v: number | null } | null>(null);
+ const timer = useRef | null>(null);
+ const clipBase = useId();
+ const step = ratingStep(scale);
+ // Leaving mid-step (navigating to another entry) still saves the stepped value.
+ const latest = useRef({ stepping, value, onChange });
+ latest.current = { stepping, value, onChange };
+ useEffect(
+ () => () => {
+ if (timer.current) clearTimeout(timer.current);
+ const { stepping: st, value: v, onChange: save } = latest.current;
+ if (st && st.v !== v) save(st.v);
+ },
+ [],
+ );
+
+ if (scale === "10" || scale === "100") {
+ const max = scale === "10" ? 10 : 100;
+ const shown = value === null ? "" : String(scale === "10" ? snapRating(value, scale) / 10 : value);
+ return (
+
+ {label}
+ {
+ const raw = e.currentTarget.value.trim();
+ if (raw === "") return value !== null && onChange(null);
+ const n = Number(raw);
+ if (Number.isNaN(n)) return;
+ const v = snapRating(scale === "10" ? n * 10 : n, scale);
+ if (v !== value) onChange(v);
+ }}
+ />
+ / {max}
+
+ );
+ }
+
+ const set = (v: number | null) => {
+ if (timer.current) clearTimeout(timer.current);
+ timer.current = null;
+ setStepping(null);
+ if (v !== value) onChange(v === null ? null : snapRating(v, scale));
+ };
+ const flush = () => stepping && set(stepping.v);
+ const current = stepping ? stepping.v : value;
+ const onKey = (e: KeyboardEvent) => {
+ const cur = current ?? 0;
+ const map: Record = {
+ ArrowRight: Math.min(100, cur + step),
+ ArrowUp: Math.min(100, cur + step),
+ ArrowLeft: Math.max(0, cur - step),
+ ArrowDown: Math.max(0, cur - step),
+ Home: 0,
+ End: 100,
+ Delete: null,
+ Backspace: null,
+ };
+ if (!(e.key in map)) return;
+ e.preventDefault();
+ const next = map[e.key]!;
+ setStepping({ v: next });
+ if (timer.current) clearTimeout(timer.current);
+ timer.current = setTimeout(() => set(next), 500);
+ };
+ const valueAt = (e: PointerEvent, i: number) => {
+ const r = e.currentTarget.getBoundingClientRect();
+ const half = scale === "5half" && e.clientX - r.left < r.width / 2;
+ return (i + (half ? 0.5 : 1)) * 20;
+ };
+ const shown = hover ?? current ?? 0;
+
+ return (
+
+ setHover(null)}
+ >
+ {[0, 1, 2, 3, 4].map((i) => {
+ const fill = Math.max(0, Math.min(1, shown / 20 - i));
+ const id = `${clipBase}-${i}`;
+ return (
+ setHover(valueAt(e, i))}
+ onClick={(e) => {
+ const v = valueAt(e as unknown as PointerEvent, i);
+ set(v === value ? null : v);
+ }}
+ >
+
+
+
+
+
+
+
+ {fill > 0 && }
+
+
+ );
+ })}
+
+ {current !== null && (
+ set(null)}>
+ Clear
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/hooks.ts b/apps/web/src/hooks.ts
new file mode 100644
index 0000000..87c9310
--- /dev/null
+++ b/apps/web/src/hooks.ts
@@ -0,0 +1,46 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useEffect, useState } from "react";
+import { api, type ChangeEvent, type EntryPatch } from "./api.ts";
+
+/** Pending Changes-inbox events (first 200; the badge shows "200+" beyond that). */
+export function usePendingChanges() {
+ return useQuery({ queryKey: ["changes", "pending"], queryFn: api.changes, staleTime: 30_000 });
+}
+
+export function useMe() {
+ return useQuery({ queryKey: ["me"], queryFn: api.me, retry: false, staleTime: Infinity });
+}
+
+export function useSources() {
+ return useQuery({ queryKey: ["sources"], queryFn: api.sources, staleTime: 60_000 });
+}
+
+/** Pending events that touch an entry or one of its reads/plays. */
+export function changesFor(items: ChangeEvent[] | undefined, entryId: string, engagementIds: Set): ChangeEvent[] {
+ return (items ?? []).filter(
+ (c) => c.targetId !== null && ((c.targetEntity === "entry" && c.targetId === entryId) || (c.targetEntity === "engagement" && engagementIds.has(c.targetId))),
+ );
+}
+
+export function useDebounced(value: T, ms = 220): T {
+ const [v, setV] = useState(value);
+ useEffect(() => {
+ const t = setTimeout(() => setV(value), ms);
+ return () => clearTimeout(t);
+ }, [value, ms]);
+ return v;
+}
+
+/** Shared save path for every detail edit: PATCH, then refresh the entry and the list. */
+export function useEntryPatch(id: string) {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: (patch: EntryPatch) => api.patchEntry(id, patch),
+ onSuccess: (entry) => {
+ qc.setQueryData(["entry", id], entry);
+ void qc.invalidateQueries({ queryKey: ["entries"] });
+ void qc.invalidateQueries({ queryKey: ["history", id] });
+ },
+ });
+}
+
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
new file mode 100644
index 0000000..bcac482
--- /dev/null
+++ b/apps/web/src/main.tsx
@@ -0,0 +1,73 @@
+import { QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { StrictMode, type ReactNode } from "react";
+import { createRoot } from "react-dom/client";
+import { Redirect, Route, Switch } from "wouter";
+import { ApiError } from "./api.ts";
+import { Shell } from "./components/Shell.tsx";
+import { useMe } from "./hooks.ts";
+import { Library } from "./routes/library.tsx";
+import { Login } from "./routes/login.tsx";
+import { NotYet } from "./routes/not-yet.tsx";
+import { Register } from "./routes/register.tsx";
+
+const queryClient: QueryClient = new QueryClient({
+ queryCache: new QueryCache({
+ // A 401 anywhere means the session ended: re-check who we are, which sends the user to sign in.
+ onError: (e, query) => {
+ if (e instanceof ApiError && e.status === 401 && query.queryKey[0] !== "me") void queryClient.invalidateQueries({ queryKey: ["me"] });
+ },
+ }),
+ defaultOptions: {
+ queries: {
+ staleTime: 15_000,
+ retry: (n, e) => !(e instanceof ApiError && e.status > 0 && e.status < 500) && n < 2,
+ },
+ },
+});
+
+function RequireAuth({ children }: { children: ReactNode }) {
+ const me = useMe();
+ if (me.isPending) return STTracker
;
+ if (me.error instanceof ApiError && me.error.status === 401) return ;
+ if (me.error) {
+ return (
+
+
+
STTracker
+
Can't load your library
+
{me.error.message}
+
void me.refetch()}>Try again
+
+
+ );
+ }
+ return <>{children}>;
+}
+
+function App() {
+ return (
+
+
+
+
+
+
+
+ {() => }
+ {(p) => }
+ {(p) => }
+
+
+
+
+
+ );
+}
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+ ,
+);
diff --git a/apps/web/src/routes/library.tsx b/apps/web/src/routes/library.tsx
new file mode 100644
index 0000000..cd33327
--- /dev/null
+++ b/apps/web/src/routes/library.tsx
@@ -0,0 +1,228 @@
+import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { useState, type FormEvent } from "react";
+import { Link, useLocation } from "wouter";
+import { ApiError, api, type EntryView } from "../api.ts";
+import { Cover } from "../components/Cover.tsx";
+import { EntryDetail } from "../components/EntryDetail.tsx";
+import { Icon } from "../components/Icon.tsx";
+import { describeRating, formatRating, mediaTypeLabel, rollupLabel, statusLabel, type Status } from "../format.ts";
+import { useDebounced, usePendingChanges } from "../hooks.ts";
+import { useRatingScale } from "../settings.ts";
+
+const TYPE_FILTERS = ["book", "manga", "comic", "game", "movie", "show", "podcast", "audio_drama", "album", "artist", "video", "other"];
+const STATUS_FILTERS: Status[] = ["in_progress", "planned", "completed", "paused", "dropped"];
+
+/** The library: list pane (search, quick add, filters, rows) and the detail pane. */
+export function Library({ selectedId }: { selectedId: string | null }) {
+ return (
+
+
+
+ {selectedId ? (
+
+ ) : (
+
+
+ Nothing selected
+ Pick something from your library, or quick add a title and sort out the details later.
+
+
+ )}
+
+
+ );
+}
+
+// Filters survive navigating between entries (the list pane remounts on route change).
+let savedFilters: { q: string; mediaType?: string; status?: Status; draft: boolean } = { q: "", draft: false };
+
+function ListPane({ selectedId }: { selectedId: string | null }) {
+ const [, navigate] = useLocation();
+ const qc = useQueryClient();
+ const scale = useRatingScale();
+ const [filters, setFiltersState] = useState(savedFilters);
+ const setFilters = (f: typeof filters) => {
+ savedFilters = f;
+ setFiltersState(f);
+ };
+ const q = useDebounced(filters.q.trim());
+ const query = { q: q || undefined, mediaType: filters.mediaType, status: filters.status, draft: filters.draft || undefined };
+ const entries = useInfiniteQuery({
+ queryKey: ["entries", query],
+ queryFn: ({ pageParam }) => api.entries({ ...query, cursor: pageParam, limit: 50 }),
+ initialPageParam: undefined as string | undefined,
+ getNextPageParam: (last) => last.nextCursor ?? undefined,
+ });
+ const changes = usePendingChanges();
+ const flagged = new Set((changes.data?.items ?? []).filter((c) => c.targetEntity === "entry").map((c) => c.targetId));
+
+ const [quickError, setQuickError] = useState(null);
+ const quick = useMutation({
+ mutationFn: api.quickAdd,
+ onSuccess: (entry, _t) => {
+ qc.setQueryData(["entry", entry.id], entry);
+ void qc.invalidateQueries({ queryKey: ["entries"] });
+ navigate(`/entries/${entry.id}`);
+ },
+ onError: (e) => setQuickError(e instanceof ApiError ? e.message : "Couldn't add that. Try again."),
+ });
+ function onQuick(e: FormEvent) {
+ e.preventDefault();
+ const input = e.currentTarget.elements.namedItem("title") as HTMLInputElement;
+ const title = input.value.trim();
+ if (!title || quick.isPending) return;
+ setQuickError(null);
+ quick.mutate(title, { onSuccess: () => (input.value = "") });
+ }
+
+ const items = entries.data?.pages.flatMap((p) => p.items) ?? [];
+ const filtering = Boolean(q || filters.mediaType || filters.status || filters.draft);
+
+ return (
+
+
+
+
+ {quickError &&
{quickError}
}
+
+
+ setFilters({ ...filters, draft: !filters.draft })}
+ >
+ Draft
+
+ {STATUS_FILTERS.map((s) => (
+ setFilters({ ...filters, status: filters.status === s ? undefined : s })}
+ >
+ {statusLabel(s)}
+
+ ))}
+
+
+ {TYPE_FILTERS.map((t) => (
+ setFilters({ ...filters, mediaType: filters.mediaType === t ? undefined : t })}
+ >
+ {mediaTypeLabel(t)}
+
+ ))}
+
+
+
+
+ {entries.isPending ? (
+
+ {[0, 1, 2, 3].map((i) => (
+
+
+
+
+ ))}
+
+ ) : entries.error ? (
+
+
{entries.error.message}
+
void entries.refetch()}>Try again
+
+ ) : items.length === 0 ? (
+
+ {filtering ? (
+ <>
+
Nothing matches these filters.
+
setFilters({ q: "", draft: false })}>Clear filters
+ >
+ ) : (
+
+ Your library is empty. Type a title into Quick add and press Enter. A title alone is
+ enough; you can link it to a catalogue entry later.
+
+ )}
+
+ ) : (
+
+ {items.map((e) => (
+
+
+
+ ))}
+ {entries.hasNextPage && (
+
+ void entries.fetchNextPage()}>
+ {entries.isFetchingNextPage ? "Loading…" : "Show more"}
+
+
+ )}
+
+ )}
+
+ );
+}
+
+/** `media type · meta`: children roll-up, else status, plus the year. */
+export function rowMeta(e: EntryView): string {
+ const type = e.mediaTypeOverride ?? e.work.mediaType;
+ const parts = [mediaTypeLabel(type)];
+ const rollup = rollupLabel(type, e.childSummary);
+ if (rollup) parts.push(rollup);
+ else if (e.status !== "none") parts.push(statusLabel(e.status, type).toLowerCase());
+ if (e.work.year) parts.push(String(e.work.year));
+ return parts.join(" · ");
+}
+
+function Row({ entry: e, selected, flagged, scale }: { entry: EntryView; selected: boolean; flagged: boolean; scale: ReturnType }) {
+ return (
+
+
+
+
{e.work.title}
+
{rowMeta(e)}
+
+
+ {flagged && }
+ {e.draft ? (
+ Draft
+ ) : e.rating !== null ? (
+ {formatRating(e.rating, scale)}
+ ) : e.status === "in_progress" ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx
new file mode 100644
index 0000000..2de2c6c
--- /dev/null
+++ b/apps/web/src/routes/login.tsx
@@ -0,0 +1,51 @@
+import { useQueryClient } from "@tanstack/react-query";
+import { useState, type FormEvent } from "react";
+import { Link, useLocation } from "wouter";
+import { ApiError, api } from "../api.ts";
+
+export function Login() {
+ const qc = useQueryClient();
+ const [, navigate] = useLocation();
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ async function submit(e: FormEvent) {
+ e.preventDefault();
+ const f = new FormData(e.currentTarget);
+ setBusy(true);
+ setError(null);
+ try {
+ const user = await api.login(String(f.get("username")), String(f.get("password")));
+ qc.setQueryData(["me"], user);
+ navigate("/", { replace: true });
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : "Sign-in failed. Try again.");
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+
STTracker
+
Sign in
+
Everything you read, watch, play and listen to, in one place that's yours.
+
+
+ First time here? Create the admin account
+
+
+
+ );
+}
diff --git a/apps/web/src/routes/not-yet.tsx b/apps/web/src/routes/not-yet.tsx
new file mode 100644
index 0000000..e250488
--- /dev/null
+++ b/apps/web/src/routes/not-yet.tsx
@@ -0,0 +1,24 @@
+const NAMES: Record = {
+ log: "Log",
+ lists: "Lists",
+ owned: "Owned",
+ changes: "Changes",
+ sources: "Sources",
+ settings: "Settings",
+ about: "About",
+};
+
+/** Sections that arrive in the next update. Unknown paths say so plainly. */
+export function NotYet({ page }: { page: string }) {
+ const name = NAMES[page];
+ return (
+
+ {name ?? "Nothing here"}
+
+ {name
+ ? `${name} isn't built yet. Your data is safe; this screen arrives in the next update.`
+ : "This address doesn't match any page. Head back to your library."}
+
+
+ );
+}
diff --git a/apps/web/src/routes/register.tsx b/apps/web/src/routes/register.tsx
new file mode 100644
index 0000000..6fc28c5
--- /dev/null
+++ b/apps/web/src/routes/register.tsx
@@ -0,0 +1,92 @@
+import { useQueryClient } from "@tanstack/react-query";
+import { useState, type FormEvent } from "react";
+import { Link, useLocation, useSearch } from "wouter";
+import { ApiError, api } from "../api.ts";
+
+/**
+ * First-run screen. The first account becomes the admin; later accounts need an invite code. The API
+ * cannot say whether users exist, so the screen assumes first run and switches to invite mode when
+ * the server answers `invite_required` (or the link carries `?invite=`).
+ */
+export function Register() {
+ const qc = useQueryClient();
+ const [, navigate] = useLocation();
+ const invite = new URLSearchParams(useSearch()).get("invite") ?? "";
+ const [needInvite, setNeedInvite] = useState(invite !== "");
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ async function submit(e: FormEvent) {
+ e.preventDefault();
+ const f = new FormData(e.currentTarget);
+ const username = String(f.get("username")).trim().toLowerCase();
+ const password = String(f.get("password"));
+ const displayName = String(f.get("displayName") ?? "").trim();
+ const inviteCode = String(f.get("inviteCode") ?? "").trim();
+ setBusy(true);
+ setError(null);
+ try {
+ await api.register({ username, password, displayName: displayName || undefined, inviteCode: inviteCode || undefined });
+ qc.setQueryData(["me"], await api.login(username, password));
+ navigate("/", { replace: true });
+ } catch (err) {
+ if (err instanceof ApiError && err.code === "invite_required") {
+ setNeedInvite(true);
+ setError(inviteCode ? "That invite code didn't work. Check it, or ask the admin for a new one." : "This STTracker already has an admin. Ask them for an invite code and enter it below.");
+ } else setError(err instanceof ApiError ? err.message : "Couldn't create the account. Try again.");
+ setBusy(false);
+ }
+ }
+
+ return (
+
+
+
STTracker
+
{needInvite ? "Create your account" : "Create the admin account"}
+
+ {needInvite
+ ? "You've been invited to this STTracker. Pick a username and password."
+ : "This is the first account on this STTracker. It can invite friends later."}
+
+
+
+ Already have an account? Sign in
+
+
+
+ );
+}
diff --git a/apps/web/src/settings.ts b/apps/web/src/settings.ts
new file mode 100644
index 0000000..3c0c568
--- /dev/null
+++ b/apps/web/src/settings.ts
@@ -0,0 +1,38 @@
+import { useSyncExternalStore } from "react";
+import type { RatingScale } from "./format.ts";
+
+/**
+ * The rating scale preference. The API has no preferences endpoint yet, so it lives in this browser
+ * (localStorage) and defaults to five stars with halves. Settings (Task 18) writes it with `setRatingScale`.
+ */
+const KEY = "sttracker.ratingScale";
+const SCALES: RatingScale[] = ["5half", "5", "10", "100"];
+const listeners = new Set<() => void>();
+
+function read(): RatingScale {
+ try {
+ const v = localStorage.getItem(KEY);
+ return SCALES.includes(v as RatingScale) ? (v as RatingScale) : "5half";
+ } catch {
+ return "5half";
+ }
+}
+
+export function setRatingScale(scale: RatingScale): void {
+ localStorage.setItem(KEY, scale);
+ for (const l of listeners) l();
+}
+
+function subscribe(cb: () => void): () => void {
+ listeners.add(cb);
+ const onStorage = (e: StorageEvent) => e.key === KEY && cb();
+ window.addEventListener("storage", onStorage);
+ return () => {
+ listeners.delete(cb);
+ window.removeEventListener("storage", onStorage);
+ };
+}
+
+export function useRatingScale(): RatingScale {
+ return useSyncExternalStore(subscribe, read, () => "5half");
+}
diff --git a/apps/web/test/api.test.ts b/apps/web/test/api.test.ts
index 777f462..22ff8d6 100644
--- a/apps/web/test/api.test.ts
+++ b/apps/web/test/api.test.ts
@@ -19,7 +19,7 @@ describe("request", () => {
headers: { "content-type": "application/json" },
}),
);
- const err = await request("POST", "entries/quick", { body: { title: "" }, fetchImpl: fn }).catch((e) => e);
+ const err = (await request("POST", "entries/quick", { body: { title: "" }, fetchImpl: fn }).catch((e: unknown) => e)) as ApiError;
expect(err).toBeInstanceOf(ApiError);
expect(err.code).toBe("validation");
expect(err.status).toBe(400);
@@ -74,7 +74,7 @@ describe("request", () => {
test("a non-JSON error still becomes an ApiError", async () => {
const { fn } = fakeFetch(new Response("Bad gateway", { status: 502 }));
- const err = await request("GET", "me", { fetchImpl: fn }).catch((e) => e);
+ const err = (await request("GET", "me", { fetchImpl: fn }).catch((e: unknown) => e)) as ApiError;
expect(err).toBeInstanceOf(ApiError);
expect(err.code).toBe("http_502");
expect(err.status).toBe(502);
@@ -84,7 +84,7 @@ describe("request", () => {
const { fn } = fakeFetch(() => {
throw new TypeError("fetch failed");
});
- const err = await request("GET", "me", { fetchImpl: fn }).catch((e) => e);
+ const err = (await request("GET", "me", { fetchImpl: fn }).catch((e: unknown) => e)) as ApiError;
expect(err).toBeInstanceOf(ApiError);
expect(err.code).toBe("network");
expect(err.status).toBe(0);