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:
co-authored by
Claude Opus 5.5
parent
d204775235
commit
5fa59310f3
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"}>
|
||||
|
||||
Reference in New Issue
Block a user