feat(web): app shell, library, entry detail

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 21:37:56 +01:00
co-authored by Claude Opus 5.5
parent 70bc6c36d3
commit 439d138967
16 changed files with 1743 additions and 3 deletions
+189
View File
@@ -0,0 +1,189 @@
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 { engagementNouns, formatPartialDate, mediaTypeLabel, 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." : entry.error.message}
</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) {
const past = nouns.one === "read" ? "Read" : nouns.one === "play" ? "Played" : nouns.one === "watch" ? "Watched" : nouns.one === "listen" ? "Listened" : "Finished";
meta.push(`${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 entry={entry} onChange={(tags) => patch.mutate({ tags })} />
{patch.error && <p className="error-text" role="alert">{patch.error.message}</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";
}
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
function shortDate(iso: string): string {
const d = new Date(iso);
return `${d.getDate()} ${MONTHS[d.getMonth()]}`;
}
function Tags({ entry, onChange }: { entry: EntryView; onChange: (tags: string[]) => void }) {
const [draft, setDraft] = useState("");
function add() {
const t = draft.trim();
if (!t) return;
if (!entry.tags.some((x) => x.toLowerCase() === t.toLowerCase())) onChange([...entry.tags, t]);
setDraft("");
}
return (
<ul className="pills" aria-label="Tags" style={{ listStyle: "none" }}>
{entry.tags.map((t) => (
<li key={t} className="pill">
{t}
<button type="button" aria-label={`Remove tag ${t}`} onClick={() => onChange(entry.tags.filter((x) => x !== 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>
);
}