- 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]>
103 lines
4.4 KiB
TypeScript
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>
|
|
);
|
|
}
|