- 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>
239 lines
9.6 KiB
TypeScript
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>
|
|
);
|
|
}
|