feat(web): app shell, library, entry detail
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
70bc6c36d3
commit
439d138967
16 files changed
+1743
-3
No files matched your search
@@ -0,0 +1,102 @@
|
||||
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 { 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">{error.message}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user