Files
STTracker/apps/web/src/components/LinkToCatalogue.tsx
T
Lucas WintherandClaude Opus 5.5 5fa59310f3 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]>
2026-10-30 22:03:51 +01:00

103 lines
4.4 KiB
TypeScript

import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, type ReactNode } from "react";
import { useLocation } from "wouter";
import { api, type EntryView, type Work } from "../api.ts";
import { friendlyError, mediaTypeLabel } from "../format.ts";
import { useDebounced } from "../hooks.ts";
import { Cover } from "./Cover.tsx";
import { Icon } from "./Icon.tsx";
/** Drafts only: suggested matches and a catalogue search. Linking keeps the user's rating, reads and review. */
export function LinkToCatalogue({ entry }: { entry: EntryView }) {
const qc = useQueryClient();
const [, navigate] = useLocation();
const [q, setQ] = useState("");
const term = useDebounced(q.trim());
const suggestions = useQuery({ queryKey: ["suggestions", entry.workId], queryFn: () => api.suggestions(entry.workId) });
const candidates = useQueries({
queries: (suggestions.data ?? []).slice(0, 5).map((s) => ({ queryKey: ["work", s.candidateWorkId], queryFn: () => api.work(s.candidateWorkId), staleTime: 300_000 })),
});
const search = useQuery({
queryKey: ["works-search", term],
queryFn: () => api.searchWorks(term),
enabled: term.length >= 2,
});
const done = (newId?: string) => {
void qc.invalidateQueries({ queryKey: ["entries"] });
void qc.invalidateQueries({ queryKey: ["entry", entry.id] });
void qc.invalidateQueries({ queryKey: ["suggestions", entry.workId] });
if (newId && newId !== entry.id) navigate(`/entries/${newId}`, { replace: true });
};
const move = useMutation({ mutationFn: (workId: string) => api.moveEntry(entry.id, workId), onSuccess: (e) => done(e.id) });
const accept = useMutation({ mutationFn: api.acceptSuggestion, onSuccess: () => done() });
const reject = useMutation({ mutationFn: api.rejectSuggestion, onSuccess: () => done() });
const busy = move.isPending || accept.isPending || reject.isPending;
const error = move.error ?? accept.error ?? reject.error;
const row = (w: Work, actions: ReactNode) => (
<li key={w.id}>
<Cover url={w.cover_url} mediaType={w.media_type} draft={false} size="small" />
<div style={{ minWidth: 0 }}>
<div>{w.title}</div>
<div className="m">{[mediaTypeLabel(w.media_type), w.year, w.creators?.[0]?.name].filter(Boolean).join(" · ")}</div>
</div>
<div className="acts">{actions}</div>
</li>
);
const results = (search.data ?? []).filter((w) => w.id !== entry.workId);
return (
<section className="box link-box" aria-labelledby="link-title">
<div className="box-head">
<h2 id="link-title"><Icon name="link" /> Link to catalogue</h2>
</div>
<p>This is a draft. Link it to a catalogue entry to fill in the cover and details. Your rating, reads and review stay as they are.</p>
{candidates.length > 0 && (
<>
<h3 className="sub">Suggested</h3>
<ul className="results">
{suggestions.data!.slice(0, 5).map((s, i) => {
const w = candidates[i]?.data;
if (!w) return null;
return row(
w,
<>
<button type="button" className="btn primary small" disabled={busy} onClick={() => accept.mutate(s.id)}>Link</button>
<button type="button" className="btn ghost small" disabled={busy} onClick={() => reject.mutate(s.id)}>Not this</button>
</>,
);
})}
</ul>
</>
)}
<div className="search">
<Icon name="search" />
<label htmlFor="catalogue-search" className="sr-only">Search the catalogue</label>
<input
id="catalogue-search"
className="input"
type="search"
placeholder="Search the catalogue…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
{term.length >= 2 && !search.isPending && (
results.length === 0 ? (
<p className="hint">Nothing in the catalogue matches “{term}”. Connect a source to bring in more.</p>
) : (
<ul className="results" aria-label="Search results">
{results.map((w) =>
row(w, <button type="button" className="btn primary small" disabled={busy} onClick={() => move.mutate(w.id)}>Link</button>),
)}
</ul>
)
)}
{error && <p className="error-text" role="alert">{friendlyError(error)}</p>}
</section>
);
}