fix(web): review round 1: optimistic edits, friendly errors, preferences, Gelasio

- 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]>
This commit is contained in:
Lucas Winther
2026-10-30 22:03:51 +01:00
co-authored by Claude Opus 5.5
parent d204775235
commit 5fa59310f3
24 changed files with 506 additions and 136 deletions
+48 -33
View File
@@ -1,7 +1,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, type FormEvent, type ReactNode } from "react";
import { useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
import { Link } from "wouter";
import { ApiError, api, type Attachment, type Copy, type CustomFieldDef, type Engagement, type EntryView } from "../api.ts";
import { api, type Copy, type CustomFieldDef, type Engagement, type EntryView } from "../api.ts";
import { orderEngagements, setCustomValue } from "../edits.ts";
import {
actorConnectionId,
describeRating,
@@ -9,6 +10,7 @@ import {
formatDateRange,
formatPartialDate,
formatRating,
friendlyError,
ordinal,
parsePartialDate,
rollupLabel,
@@ -33,7 +35,6 @@ function Box({ title, id, action, wide, className, children }: { title: string;
);
}
const errText = (e: unknown) => (e instanceof ApiError ? e.message : "That didn't save. Try again.");
// ---------------------------------------------------------------------------------------------
// Reads / plays
@@ -63,9 +64,14 @@ export function EngagementsBox({
setAdding(false);
refresh();
},
onError: (e) => setFormError(errText(e)),
onError: (e) => setFormError(friendlyError(e)),
});
const [removeError, setRemoveError] = useState<string | null>(null);
const remove = useMutation({
mutationFn: api.deleteEngagement,
onSuccess: () => (setRemoveError(null), refresh()),
onError: (e) => setRemoveError(friendlyError(e)),
});
const remove = useMutation({ mutationFn: api.deleteEngagement, onSuccess: refresh });
function submit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
@@ -84,7 +90,7 @@ export function EngagementsBox({
}
// Oldest is the 1st; show newest first, like the mockup.
const ordered = [...(engagements ?? [])].sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1));
const ordered = orderEngagements(engagements ?? []);
const rows = ordered.map((g, i) => ({ g, n: i + 1 })).reverse();
return (
@@ -120,6 +126,7 @@ export function EngagementsBox({
</div>
))
)}
{removeError && <p className="error-text" role="alert">{removeError}</p>}
{adding && (
<form onSubmit={submit} aria-label={nouns.log}>
<div className="two">
@@ -170,7 +177,7 @@ export function CopyBox({ entry }: { entry: EntryView }) {
setAdding(false);
void qc.invalidateQueries({ queryKey: ["copies", entry.workId] });
},
onError: (e) => setFormError(errText(e)),
onError: (e) => setFormError(friendlyError(e)),
});
function submit(e: FormEvent<HTMLFormElement>) {
@@ -255,21 +262,18 @@ export function CopyBox({ entry }: { entry: EntryView }) {
// Attachments
// ---------------------------------------------------------------------------------------------
// The API has no "list attachments of an entry" route yet, so this shows what was uploaded in this session.
const uploaded = new Map<string, Attachment[]>();
export function AttachmentsBox({ entry }: { entry: EntryView }) {
const [items, setItems] = useState<Attachment[]>(() => uploaded.get(entry.id) ?? []);
const qc = useQueryClient();
const list = useQuery({ queryKey: ["attachments", entry.id], queryFn: () => api.attachments("entry", entry.id) });
const items = list.data ?? [];
const [error, setError] = useState<string | null>(null);
const upload = useMutation({
mutationFn: (file: File) => api.upload(file, entry.id),
onSuccess: (a) => {
const next = [...items, a];
uploaded.set(entry.id, next);
setItems(next);
onSuccess: () => {
setError(null);
void qc.invalidateQueries({ queryKey: ["attachments", entry.id] });
},
onError: (e) => setError(e instanceof ApiError && e.code === "validation" ? "That file type isn't supported. Use an image, a PDF or a text file." : errText(e)),
onError: (e) => setError(friendlyError(e)),
});
return (
<Box title="Attachments" id="box-attachments">
@@ -309,7 +313,8 @@ export function AttachmentsBox({ entry }: { entry: EntryView }) {
</label>
</div>
{error && <p className="error-text" role="alert">{error}</p>}
{items.length === 0 && !error && <p className="hint">Screenshots, photos of your copy, receipts.</p>}
{list.error && <p className="error-text" role="alert">{friendlyError(list.error)}</p>}
{items.length === 0 && !error && !list.isPending && <p className="hint">Screenshots, photos of your copy, receipts.</p>}
</Box>
);
}
@@ -353,12 +358,11 @@ export function CustomFieldsBox({ entry, type }: { entry: EntryView; type: strin
const defs = useQuery({ queryKey: ["custom-fields", type], queryFn: () => api.customFields(type), staleTime: 60_000 });
const patch = useEntryPatch(entry.id);
if (!defs.data?.length) return null;
const save = (def: CustomFieldDef, value: unknown) => {
const next = { ...entry.customFields };
if (value === null || value === "" || (Array.isArray(value) && value.length === 0)) delete next[def.id];
else next[def.id] = value;
if (JSON.stringify(next) !== JSON.stringify(entry.customFields)) patch.mutate({ customFields: next });
};
const save = (def: CustomFieldDef, value: unknown) =>
patch.edit((cur) => {
const next = setCustomValue(cur.customFields, def.id, value);
return JSON.stringify(next) === JSON.stringify(cur.customFields) ? null : { customFields: next };
});
return (
<Box title="Details" id="box-custom" wide>
<div className="custom">
@@ -366,7 +370,7 @@ export function CustomFieldsBox({ entry, type }: { entry: EntryView; type: strin
<CustomField key={d.id} def={d} value={entry.customFields[d.id]} onSave={(v) => save(d, v)} />
))}
</div>
{patch.error && <p className="error-text" role="alert">{errText(patch.error)}</p>}
{patch.error && <p className="error-text" role="alert">{friendlyError(patch.error)}</p>}
</Box>
);
}
@@ -468,7 +472,7 @@ export function ChildrenBox({ entry, type }: { entry: EntryView; type: string })
<Link href={`/entries/${c.id}`}>
<span className="t">
{c.work.title}
<span className="s">{statusLabel(c.status, c.work.mediaType)}</span>
<span className="s">{statusLabel(c.status, c.mediaTypeOverride ?? c.work.mediaType)}</span>
</span>
{c.rating !== null ? (
<span className="stars" role="img" aria-label={describeRating(c.rating, scale)}>{formatRating(c.rating, scale)}</span>
@@ -490,9 +494,20 @@ export function ChildrenBox({ entry, type }: { entry: EntryView; type: string })
export function ReviewBox({ entry }: { entry: EntryView }) {
const patch = useEntryPatch(entry.id);
const [saved, setSaved] = useState(false);
const save = (v: string) => {
const next = v.trim() === "" ? null : v;
if (next === entry.review) return;
const [text, setText] = useState(entry.review ?? "");
// The server value this text was last in sync with. When the review changes elsewhere (Restore,
// another tab) and the user has not typed since, show the new value, so a later blur can't undo it.
const synced = useRef(entry.review ?? "");
useEffect(() => {
const incoming = entry.review ?? "";
if (incoming === synced.current) return;
setText((cur) => (cur === synced.current ? incoming : cur));
synced.current = incoming;
}, [entry.review]);
const save = () => {
const next = text.trim() === "" ? null : text;
if ((next ?? "") === (entry.review ?? "")) return;
synced.current = next ?? "";
patch.mutate({ review: next }, { onSuccess: () => setSaved(true) });
};
return (
@@ -506,15 +521,15 @@ export function ReviewBox({ entry }: { entry: EntryView }) {
<label htmlFor="review-text" className="sr-only">Your review (Markdown)</label>
<textarea
id="review-text"
defaultValue={entry.review ?? ""}
value={text}
placeholder="What stayed with you? Markdown works here."
onChange={() => setSaved(false)}
onBlur={(e) => save(e.target.value)}
onChange={(e) => (setText(e.target.value), setSaved(false))}
onBlur={save}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) save(e.currentTarget.value);
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) save();
}}
/>
{patch.error && <p className="error-text" role="alert">{errText(patch.error)}</p>}
{patch.error && <p className="error-text" role="alert">{friendlyError(patch.error)}</p>}
</Box>
);
}
+14 -18
View File
@@ -2,7 +2,8 @@ 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 { 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";
@@ -23,7 +24,7 @@ export function EntryDetail({ id }: { id: string }) {
<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}
{gone ? "It may have been deleted or moved. Pick another from your library." : friendlyError(entry.error)}
</p>
</div>
);
@@ -51,8 +52,7 @@ function Detail({ entry }: { entry: EntryView }) {
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`}`);
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)}`);
}
@@ -91,8 +91,12 @@ function Detail({ entry }: { entry: EntryView }) {
</button>
</div>
</div>
<Tags entry={entry} onChange={(tags) => patch.mutate({ tags })} />
{patch.error && <p className="error-text" role="alert">{patch.error.message}</p>}
<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>
@@ -141,26 +145,18 @@ function whoChanged(pending: { connectionId: string | null }[], sources: { id: s
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 }) {
function Tags({ tags, onAdd, onRemove }: { tags: string[]; onAdd: (t: string) => void; onRemove: (t: 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]);
if (draft.trim()) onAdd(draft);
setDraft("");
}
return (
<ul className="pills" aria-label="Tags" style={{ listStyle: "none" }}>
{entry.tags.map((t) => (
{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))}>
<button type="button" aria-label={`Remove tag ${t}`} onClick={() => onRemove(t)}>
<Icon name="x" />
</button>
</li>
+4 -4
View File
@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import { api, type EntryView, type HistoryRecord } from "../api.ts";
import { actorConnectionId, formatPartialDate, formatRating, statusLabel, timeAgo, type PartialDate, type Status } from "../format.ts";
import { actorConnectionId, formatPartialDate, formatRating, friendlyError, statusLabel, timeAgo, type PartialDate, type Status } from "../format.ts";
import { useSources } from "../hooks.ts";
import { useRatingScale } from "../settings.ts";
import { Icon } from "./Icon.tsx";
@@ -52,7 +52,7 @@ export function HistoryDrawer({ entry, onClose }: { entry: EntryView; onClose: (
const show = (field: string, v: unknown): string => {
if (v === null || v === undefined || v === "") return "empty";
if (field === "rating") return formatRating(v as number, scale);
if (field === "status") return statusLabel(v as Status, entry.work.mediaType);
if (field === "status") return statusLabel(v as Status, entry.mediaTypeOverride ?? entry.work.mediaType);
if (Array.isArray(v)) return v.length ? v.join(", ") : "none";
if (typeof v === "object" && "precision" in (v as object)) return formatPartialDate(v as PartialDate) || "unknown";
if (typeof v === "object") return Object.keys(v as object).length ? "edited" : "empty";
@@ -74,7 +74,7 @@ export function HistoryDrawer({ entry, onClose }: { entry: EntryView; onClose: (
</button>
</header>
{history.isPending ? null : history.error ? (
<p className="error-text" style={{ padding: "0 20px" }} role="alert">{history.error.message}</p>
<p className="error-text" style={{ padding: "0 20px" }} role="alert">{friendlyError(history.error)}</p>
) : (
<ol>
{history.data.map((h) => {
@@ -109,7 +109,7 @@ export function HistoryDrawer({ entry, onClose }: { entry: EntryView; onClose: (
})}
</ol>
)}
{restore.error && <p className="error-text" role="alert" style={{ padding: "0 20px 16px" }}>{restore.error.message}</p>}
{restore.error && <p className="error-text" role="alert" style={{ padding: "0 20px 16px" }}>{friendlyError(restore.error)}</p>}
</aside>
);
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/rea
import { useState, type ReactNode } from "react";
import { useLocation } from "wouter";
import { api, type EntryView, type Work } from "../api.ts";
import { mediaTypeLabel } from "../format.ts";
import { friendlyError, mediaTypeLabel } from "../format.ts";
import { useDebounced } from "../hooks.ts";
import { Cover } from "./Cover.tsx";
import { Icon } from "./Icon.tsx";
@@ -96,7 +96,7 @@ export function LinkToCatalogue({ entry }: { entry: EntryView }) {
</ul>
)
)}
{error && <p className="error-text" role="alert">{error.message}</p>}
{error && <p className="error-text" role="alert">{friendlyError(error)}</p>}
</section>
);
}
+9 -5
View File
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
import { Link, useLocation } from "wouter";
import { api } from "../api.ts";
import { useMe, usePendingChanges, useSources } from "../hooks.ts";
import { resetLibraryState } from "../routes/library.tsx";
function NavLink({ href, active, children, className }: { href: string; active: boolean; children: ReactNode; className?: string }) {
return (
@@ -25,10 +26,12 @@ export function Shell({ children }: { children: ReactNode }) {
async function signOut() {
try {
await api.logout();
} finally {
qc.clear();
navigate("/login", { replace: true });
} catch {
// The session may already be gone; signing out locally is still right.
}
qc.clear();
resetLibraryState();
navigate("/login", { replace: true });
}
return (
@@ -43,8 +46,9 @@ export function Shell({ children }: { children: ReactNode }) {
<NavLink href="/changes" active={at("/changes")}>
Changes
{pending > 0 && (
<span className="count" aria-label={`${pending} waiting`}>
<span className="count">
{changes.data?.nextCursor ? `${pending}+` : pending}
<span className="sr-only"> waiting</span>
</span>
)}
</NavLink>
@@ -54,7 +58,7 @@ export function Shell({ children }: { children: ReactNode }) {
{(sources.data ?? []).map((s) => (
<NavLink key={s.id} href={`/sources/${s.id}`} active={at(`/sources/${s.id}`)}>
<span className="name">{s.name}</span>
<span className={`dot${s.status === "active" ? "" : " paused"}`} aria-label={s.status === "active" ? "Active" : "Paused"} />
<span className={`dot${s.status === "active" ? "" : " paused"}`} role="img" aria-label={s.status === "active" ? "Active" : "Paused"} />
</NavLink>
))}
<NavLink href="/sources" active={path === "/sources"}>