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 <noreply@anthropic.com>
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
+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>