Files
STTracker/apps/web/src/routes/library.tsx
T
Lucas WintherandClaude Opus 5.5 5fa59310f3 fix(web): review round 1: optimistic edits, friendly errors, preferences, Gelasio
- Tags and custom fields update the cached entry optimistically and build
  from the latest cache; one entry's PATCHes run in order (mutation scope).
- The review textarea follows server changes (Restore) unless edited.
- friendlyError() maps validation reasons and 413 to plain copy everywhere.
- Rating scale from me/preferences; /login and /register use auth/status;
  Attachments lists from the API.
- Gelasio vendored (official OFL variable TTFs); minors from the review.
- Docs: DESIGN (AA on orange, faint labels), DECISIONS, TODO, ARCHITECTURE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:03:51 +01:00

239 lines
9.6 KiB
TypeScript

import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useRef, useState, type FormEvent } from "react";
import { Link, useLocation } from "wouter";
import { 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, friendlyError, 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 (
<div className={`library${selectedId ? " has-selection" : ""}`}>
<ListPane selectedId={selectedId} />
<section className="detail-pane" aria-label="Entry">
{selectedId ? (
<EntryDetail key={selectedId} id={selectedId} />
) : (
<div className="detail-empty">
<p>
<strong>Nothing selected</strong>
Pick something from your library, or quick add a title and sort out the details later.
</p>
</div>
)}
</section>
</div>
);
}
// Filters survive navigating between entries (the list pane remounts on route change).
type Filters = { q: string; mediaType?: string; status?: Status; draft: boolean };
let savedFilters: Filters = { q: "", draft: false };
/** Forget per-session library state (on sign-out). */
export function resetLibraryState(): void {
savedFilters = { 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<string | null>(null);
const quickInput = useRef<HTMLInputElement>(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}`);
if (quickInput.current) quickInput.current.value = "";
quickInput.current?.focus();
},
onError: (e) => setQuickError(friendlyError(e)),
});
function onQuick(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const title = (quickInput.current?.value ?? "").trim();
if (!title || quick.isPending) return;
setQuickError(null);
quick.mutate(title);
}
const items = entries.data?.pages.flatMap((p) => p.items) ?? [];
const filtering = Boolean(q || filters.mediaType || filters.status || filters.draft);
return (
<section className="list-pane" aria-label="Library">
<div className="list-tools">
<form role="search" className="search" onSubmit={(e) => e.preventDefault()}>
<Icon name="search" />
<label className="sr-only" htmlFor="lib-search">Search your library</label>
<input
id="lib-search"
className="input"
type="search"
placeholder="Search your library…"
value={filters.q}
onChange={(e) => setFilters({ ...filters, q: e.target.value })}
/>
</form>
<form className="quick" onSubmit={onQuick}>
<span className="plus"><Icon name="plus" /></span>
<label className="sr-only" htmlFor="quick-add">Quick add</label>
<input
id="quick-add"
ref={quickInput}
name="title"
className="input"
placeholder="Quick add: type a title, sort it out later"
autoComplete="off"
maxLength={1000}
readOnly={quick.isPending}
aria-busy={quick.isPending}
aria-describedby={quickError ? "quick-error" : undefined}
/>
</form>
{quickError && <p className="error-text" id="quick-error" role="alert">{quickError}</p>}
<div className="filters">
<div className="chips" role="group" aria-label="Filter by status">
<button
type="button"
className="chip draft-chip"
aria-pressed={filters.draft}
onClick={() => setFilters({ ...filters, draft: !filters.draft })}
>
Draft
</button>
{STATUS_FILTERS.map((s) => (
<button
key={s}
type="button"
className="chip"
aria-pressed={filters.status === s}
onClick={() => setFilters({ ...filters, status: filters.status === s ? undefined : s })}
>
{statusLabel(s)}
</button>
))}
</div>
<div className="chips" role="group" aria-label="Filter by media type">
{TYPE_FILTERS.map((t) => (
<button
key={t}
type="button"
className="chip"
aria-pressed={filters.mediaType === t}
onClick={() => setFilters({ ...filters, mediaType: filters.mediaType === t ? undefined : t })}
>
{mediaTypeLabel(t)}
</button>
))}
</div>
</div>
</div>
{entries.isPending ? (
<ul className="list" aria-busy="true" aria-label="Loading your library">
{[0, 1, 2, 3].map((i) => (
<li key={i} className="row skeleton" aria-hidden="true">
<div className="cover" />
<div><div className="bar" /><div className="bar" /></div>
</li>
))}
</ul>
) : entries.error ? (
<div className="list-note" role="alert">
<p>{friendlyError(entries.error)}</p>
<button className="btn small" onClick={() => void entries.refetch()}>Try again</button>
</div>
) : items.length === 0 ? (
<div className="list-note">
{filtering ? (
<>
<p>Nothing matches these filters.</p>
<button className="btn small" onClick={() => setFilters({ q: "", draft: false })}>Clear filters</button>
</>
) : (
<p>
<strong>Your library is empty.</strong> Type a title into Quick add and press Enter. A title alone is
enough; you can link it to a catalogue entry later.
</p>
)}
</div>
) : (
<ul className="list">
{items.map((e) => (
<li key={e.id}>
<Row entry={e} selected={e.id === selectedId} flagged={flagged.has(e.id)} scale={scale} />
</li>
))}
{entries.hasNextPage && (
<li className="more">
<button className="btn small" disabled={entries.isFetchingNextPage} onClick={() => void entries.fetchNextPage()}>
{entries.isFetchingNextPage ? "Loading…" : "Show more"}
</button>
</li>
)}
</ul>
)}
</section>
);
}
/** `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<typeof useRatingScale> }) {
return (
<Link href={`/entries/${e.id}`} className="row" aria-current={selected ? "page" : undefined}>
<Cover url={e.work.coverUrl} mediaType={e.mediaTypeOverride ?? e.work.mediaType} draft={e.draft} />
<div style={{ minWidth: 0 }}>
<span className="title">{e.work.title}</span>
<div className="meta">{rowMeta(e)}</div>
</div>
<span className="end">
{flagged && <span className="flag" role="img" aria-label="A source changed this" title="A source changed this" />}
{e.draft ? (
<span className="draft">Draft</span>
) : e.rating !== null ? (
<span className="stars" role="img" aria-label={describeRating(e.rating, scale)}>{formatRating(e.rating, scale)}</span>
) : e.status === "in_progress" ? (
<span className="flag active" role="img" aria-label="In progress" title="In progress" />
) : null}
</span>
</Link>
);
}