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
; 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." : friendlyError(entry.error)}

); } 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) { 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 (
Library

{entry.work.title} {entry.draft && Draft}

{meta.join(" · ")}

patch.mutate({ rating })} />
patch.edit((cur) => ({ tags: addTag(cur.tags, t) }))} onRemove={(t) => patch.edit((cur) => ({ tags: removeTag(cur.tags, t) }))} /> {patch.error &&

{friendlyError(patch.error)}

}
{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.

)} {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"; } 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 (
    {tags.map((t) => (
  • {t}
  • ))}
); }