feat(web): app shell, library, entry detail
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
70bc6c36d3
commit
439d138967
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user