- 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 <[email protected]>
186 lines
7.4 KiB
TypeScript
186 lines
7.4 KiB
TypeScript
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 { addTag, removeTag } from "../edits.ts";
|
|
import { engagementNouns, formatPartialDate, friendlyError, mediaTypeLabel, shortDate, 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 <div className="detail" aria-busy="true" />;
|
|
if (entry.error) {
|
|
const gone = entry.error instanceof ApiError && entry.error.status === 404;
|
|
return (
|
|
<div className="detail-empty" role="alert">
|
|
<p>
|
|
<strong>{gone ? "This entry isn't here" : "Couldn't load this entry"}</strong>
|
|
{gone ? "It may have been deleted or moved. Pick another from your library." : friendlyError(entry.error)}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
return <Detail entry={entry.data} />;
|
|
}
|
|
|
|
function Detail({ entry }: { entry: EntryView }) {
|
|
const scale = useRatingScale();
|
|
const patch = useEntryPatch(entry.id);
|
|
const [, navigate] = useLocation();
|
|
const [historyOpen, setHistoryOpen] = useState(false);
|
|
const historyBtn = useRef<HTMLButtonElement>(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) {
|
|
meta.push(`${nouns.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 (
|
|
<article className="detail" aria-labelledby="entry-title">
|
|
<Link href="/" className="btn ghost back"><Icon name="back" /> Library</Link>
|
|
<header className="det-head">
|
|
<Cover url={entry.work.coverUrl} mediaType={type} draft={entry.draft} size="large" />
|
|
<div style={{ minWidth: 0 }}>
|
|
<h1 id="entry-title">
|
|
{entry.work.title}
|
|
{entry.draft && <span className="draft">Draft</span>}
|
|
</h1>
|
|
<p className="m">{meta.join(" · ")}</p>
|
|
<div className="det-controls">
|
|
<StarRating value={entry.rating} scale={scale} label="Your rating" onChange={(rating) => patch.mutate({ rating })} />
|
|
<label>
|
|
<span className="sr-only">Status</span>
|
|
<select className="select" value={entry.status} onChange={(e) => patch.mutate({ status: e.target.value as EntryView["status"] })}>
|
|
{STATUSES.map((s) => (
|
|
<option key={s} value={s}>{statusLabel(s, type)}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<div className="det-tools">
|
|
<button
|
|
ref={historyBtn}
|
|
type="button"
|
|
className="btn ghost small"
|
|
aria-expanded={historyOpen}
|
|
aria-controls="history-drawer"
|
|
onClick={() => setHistoryOpen((o) => !o)}
|
|
>
|
|
<Icon name="history" /> History
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<Tags
|
|
tags={entry.tags}
|
|
onAdd={(t) => patch.edit((cur) => ({ tags: addTag(cur.tags, t) }))}
|
|
onRemove={(t) => patch.edit((cur) => ({ tags: removeTag(cur.tags, t) }))}
|
|
/>
|
|
{patch.error && <p className="error-text" role="alert">{friendlyError(patch.error)}</p>}
|
|
</div>
|
|
</header>
|
|
|
|
{pending.length > 0 && (
|
|
<div className="alert" role="status">
|
|
<p>
|
|
<b>{whoChanged(pending, sources.data)} changed this {mediaTypeLabel(type).toLowerCase()}.</b>{" "}
|
|
{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.
|
|
</p>
|
|
<button type="button" className="btn" onClick={() => navigate(`/changes?entry=${entry.id}`)}>See what changed</button>
|
|
</div>
|
|
)}
|
|
|
|
{entry.draft && <LinkToCatalogue entry={entry} />}
|
|
|
|
<div className="det-grid">
|
|
<EngagementsBox entry={entry} nouns={nouns} engagements={engagements.data} loading={engagements.isPending} />
|
|
<CopyBox entry={entry} />
|
|
<AttachmentsBox entry={entry} />
|
|
<SourcesBox entry={entry} engagements={engagements.data ?? []} />
|
|
<CustomFieldsBox entry={entry} type={type} />
|
|
{entry.childSummary && <ChildrenBox entry={entry} type={type} />}
|
|
<ReviewBox entry={entry} />
|
|
</div>
|
|
|
|
{historyOpen && (
|
|
<HistoryDrawer
|
|
entry={entry}
|
|
onClose={() => {
|
|
setHistoryOpen(false);
|
|
historyBtn.current?.focus();
|
|
}}
|
|
/>
|
|
)}
|
|
</article>
|
|
);
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
function Tags({ tags, onAdd, onRemove }: { tags: string[]; onAdd: (t: string) => void; onRemove: (t: string) => void }) {
|
|
const [draft, setDraft] = useState("");
|
|
function add() {
|
|
if (draft.trim()) onAdd(draft);
|
|
setDraft("");
|
|
}
|
|
return (
|
|
<ul className="pills" aria-label="Tags" style={{ listStyle: "none" }}>
|
|
{tags.map((t) => (
|
|
<li key={t} className="pill">
|
|
{t}
|
|
<button type="button" aria-label={`Remove tag ${t}`} onClick={() => onRemove(t)}>
|
|
<Icon name="x" />
|
|
</button>
|
|
</li>
|
|
))}
|
|
<li>
|
|
<label>
|
|
<span className="sr-only">Add a tag</span>
|
|
<input
|
|
className="tag-input"
|
|
placeholder="+ tag"
|
|
value={draft}
|
|
maxLength={100}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" || e.key === ",") {
|
|
e.preventDefault();
|
|
add();
|
|
}
|
|
}}
|
|
onBlur={add}
|
|
/>
|
|
</label>
|
|
</li>
|
|
</ul>
|
|
);
|
|
}
|