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:
1 parent
d204775235
commit
5fa59310f3
24 files changed
+506
-136
No files matched your search
@@ -1,11 +1,11 @@
|
||||
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useRef, useState, type FormEvent } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { ApiError, api, type EntryView } from "../api.ts";
|
||||
import { api, type EntryView } from "../api.ts";
|
||||
import { Cover } from "../components/Cover.tsx";
|
||||
import { EntryDetail } from "../components/EntryDetail.tsx";
|
||||
import { Icon } from "../components/Icon.tsx";
|
||||
import { describeRating, formatRating, mediaTypeLabel, rollupLabel, statusLabel, type Status } from "../format.ts";
|
||||
import { describeRating, formatRating, friendlyError, mediaTypeLabel, rollupLabel, statusLabel, type Status } from "../format.ts";
|
||||
import { useDebounced, usePendingChanges } from "../hooks.ts";
|
||||
import { useRatingScale } from "../settings.ts";
|
||||
|
||||
@@ -34,7 +34,13 @@ export function Library({ selectedId }: { selectedId: string | null }) {
|
||||
}
|
||||
|
||||
// Filters survive navigating between entries (the list pane remounts on route change).
|
||||
let savedFilters: { q: string; mediaType?: string; status?: Status; draft: boolean } = { q: "", draft: false };
|
||||
type Filters = { q: string; mediaType?: string; status?: Status; draft: boolean };
|
||||
let savedFilters: Filters = { q: "", draft: false };
|
||||
|
||||
/** Forget per-session library state (on sign-out). */
|
||||
export function resetLibraryState(): void {
|
||||
savedFilters = { q: "", draft: false };
|
||||
}
|
||||
|
||||
function ListPane({ selectedId }: { selectedId: string | null }) {
|
||||
const [, navigate] = useLocation();
|
||||
@@ -57,22 +63,24 @@ function ListPane({ selectedId }: { selectedId: string | null }) {
|
||||
const flagged = new Set((changes.data?.items ?? []).filter((c) => c.targetEntity === "entry").map((c) => c.targetId));
|
||||
|
||||
const [quickError, setQuickError] = useState<string | null>(null);
|
||||
const quickInput = useRef<HTMLInputElement>(null);
|
||||
const quick = useMutation({
|
||||
mutationFn: api.quickAdd,
|
||||
onSuccess: (entry, _t) => {
|
||||
qc.setQueryData(["entry", entry.id], entry);
|
||||
void qc.invalidateQueries({ queryKey: ["entries"] });
|
||||
navigate(`/entries/${entry.id}`);
|
||||
if (quickInput.current) quickInput.current.value = "";
|
||||
quickInput.current?.focus();
|
||||
},
|
||||
onError: (e) => setQuickError(e instanceof ApiError ? e.message : "Couldn't add that. Try again."),
|
||||
onError: (e) => setQuickError(friendlyError(e)),
|
||||
});
|
||||
function onQuick(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget.elements.namedItem("title") as HTMLInputElement;
|
||||
const title = input.value.trim();
|
||||
const title = (quickInput.current?.value ?? "").trim();
|
||||
if (!title || quick.isPending) return;
|
||||
setQuickError(null);
|
||||
quick.mutate(title, { onSuccess: () => (input.value = "") });
|
||||
quick.mutate(title);
|
||||
}
|
||||
|
||||
const items = entries.data?.pages.flatMap((p) => p.items) ?? [];
|
||||
@@ -98,12 +106,14 @@ function ListPane({ selectedId }: { selectedId: string | null }) {
|
||||
<label className="sr-only" htmlFor="quick-add">Quick add</label>
|
||||
<input
|
||||
id="quick-add"
|
||||
ref={quickInput}
|
||||
name="title"
|
||||
className="input"
|
||||
placeholder="Quick add: type a title, sort it out later"
|
||||
autoComplete="off"
|
||||
maxLength={1000}
|
||||
disabled={quick.isPending}
|
||||
readOnly={quick.isPending}
|
||||
aria-busy={quick.isPending}
|
||||
aria-describedby={quickError ? "quick-error" : undefined}
|
||||
/>
|
||||
</form>
|
||||
@@ -157,7 +167,7 @@ function ListPane({ selectedId }: { selectedId: string | null }) {
|
||||
</ul>
|
||||
) : entries.error ? (
|
||||
<div className="list-note" role="alert">
|
||||
<p>{entries.error.message}</p>
|
||||
<p>{friendlyError(entries.error)}</p>
|
||||
<button className="btn small" onClick={() => void entries.refetch()}>Try again</button>
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
@@ -208,7 +218,7 @@ export function rowMeta(e: EntryView): string {
|
||||
function Row({ entry: e, selected, flagged, scale }: { entry: EntryView; selected: boolean; flagged: boolean; scale: ReturnType<typeof useRatingScale> }) {
|
||||
return (
|
||||
<Link href={`/entries/${e.id}`} className="row" aria-current={selected ? "page" : undefined}>
|
||||
<Cover url={e.work.coverUrl} mediaType={e.work.mediaType} draft={e.draft} />
|
||||
<Cover url={e.work.coverUrl} mediaType={e.mediaTypeOverride ?? e.work.mediaType} draft={e.draft} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<span className="title">{e.work.title}</span>
|
||||
<div className="meta">{rowMeta(e)}</div>
|
||||
|
||||
Reference in new issue
Block a user