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