feat(web): app shell, library, entry detail
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
70bc6c36d3
commit
439d138967
16 files changed
+1743
-3
No files matched your search
@@ -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 (
|
||||
<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).
|
||||
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<string | null>(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<HTMLFormElement>) {
|
||||
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 (
|
||||
<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"
|
||||
name="title"
|
||||
className="input"
|
||||
placeholder="Quick add: type a title, sort it out later"
|
||||
autoComplete="off"
|
||||
maxLength={1000}
|
||||
disabled={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>{entries.error.message}</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.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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user