Add album view and bulk actions to Review

The Review list can now be grouped by album (disc folders folded in),
showing per-album counts of upgrades, identical files, downgrades and
approximate matches. Whole albums or individual songs can be selected and
linked, promoted, ignored or un-ignored in one action.

Every bulk action is previewed server-side first: how many songs will be
processed, which are skipped and why, and warnings with examples for
downgrades, approximate matches, duration mismatches, folder moves and
renames. Downgrades are excluded unless explicitly included, and
replacing master files requires an acknowledgement. Link and promote run
as a background job that re-plans each song and uses the normal
single-item operation, so all preflight checks still apply. IDs from
another pair are refused and duplicates are ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-23 21:33:44 -07:00
1 parent 0ae4e85bd2
commit 1d31999793
9 files changed
+1209 -7

No files matched your search

+34
View File
@@ -165,3 +165,37 @@ export type TreeEntry = {
};
export type Page<T> = { items: T[]; total: number };
export type AlbumGroup = {
folder: string;
ids: number[];
songs: number;
identical: number;
review: number;
upgrades: number;
downgrades: number;
approximate: number;
other: number;
bytes: number;
master_folders: string[];
};
export type BulkAction = "link" | "promote" | "ignore" | "unignore";
export type BulkPreview = {
action: BulkAction;
selected: number;
albums: number;
process: number;
skip: Record<string, number>;
identical: number;
quarantined: number;
upgrades: number;
downgrades: number;
approximate: number;
moves_folder: number;
renames: number;
duration_mismatch: number;
bytes_freed: number;
examples: Record<string, string[] | undefined>;
};
+425
View File
@@ -0,0 +1,425 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowRight, ChevronDown, ChevronRight, Crown, Disc3, Eye, EyeOff, Link2, X } from "lucide-react";
import { api, type AlbumGroup, type BulkAction, type BulkPreview, type Match, type Page, type Pair } from "../api";
import { useInfo } from "../App";
import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes } from "../ui";
import { useToast } from "../toast";
import { MatchRow } from "./Review";
export type Selection = Set<number>;
export function toggleIDs(sel: Selection, ids: number[], on: boolean): Selection {
const n = new Set(sel);
for (const id of ids) {
if (on) n.add(id);
else n.delete(id);
}
return n;
}
/** A checkbox that shows the "some selected" state. */
export function TriCheckbox({ checked, partial, onChange, label }: { checked: boolean; partial: boolean; onChange: (v: boolean) => void; label: string }) {
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
if (ref.current) ref.current.indeterminate = partial && !checked;
}, [partial, checked]);
return (
<input
ref={ref}
type="checkbox"
aria-label={label}
className="size-4 shrink-0 accent-violet-500 cursor-pointer"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
onClick={(e) => e.stopPropagation()}
/>
);
}
const GROUP_PAGE = 30;
export function AlbumView({
pair,
status,
query,
sel,
setSel,
}: {
pair: Pair;
status: string;
query: string;
sel: Selection;
setSel: (s: Selection) => void;
}) {
const [page, setPage] = useState(0);
useEffect(() => setPage(0), [status, query]);
const groups = useQuery({
queryKey: ["matches", "groups", pair.id, status, query],
queryFn: () => api.get<AlbumGroup[]>(`/pairs/${pair.id}/groups?status=${status}&q=${encodeURIComponent(query)}`),
});
const all = groups.data ?? [];
const shown = all.slice(page * GROUP_PAGE, (page + 1) * GROUP_PAGE);
const shownIDs = shown.flatMap((g) => g.ids);
const pageSelected = shownIDs.length > 0 && shownIDs.every((id) => sel.has(id));
if (groups.isLoading)
return (
<Card className="p-8">
<Spinner />
</Card>
);
if (all.length === 0)
return (
<Card>
<Empty icon={<Disc3 className="size-8" />} title="Nothing here">
No albums have songs matching this filter.
</Empty>
</Card>
);
return (
<>
<Card className="overflow-hidden">
<label className="flex items-center gap-3 border-b border-line bg-panel-2 px-4 py-2 text-xs text-muted">
<TriCheckbox
label="Select all albums on this page"
checked={pageSelected}
partial={shownIDs.some((id) => sel.has(id))}
onChange={(v) => setSel(toggleIDs(sel, shownIDs, v))}
/>
Select all {shown.length} albums on this page
{all.length > shown.length && (
<button className="ml-2 text-accent hover:underline cursor-pointer" onClick={() => setSel(toggleIDs(sel, all.flatMap((g) => g.ids), true))}>
or all {all.length} albums ({all.reduce((n, g) => n + g.songs, 0)} songs)
</button>
)}
</label>
{shown.map((g) => (
<AlbumRow key={g.folder} pair={pair} g={g} sel={sel} setSel={setSel} />
))}
</Card>
{all.length > GROUP_PAGE && (
<div className="flex items-center justify-between text-sm text-muted">
<span>
Albums {page * GROUP_PAGE + 1}–{Math.min((page + 1) * GROUP_PAGE, all.length)} of {all.length}
</span>
<div className="flex gap-2">
<Button size="sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
Previous
</Button>
<Button size="sm" disabled={(page + 1) * GROUP_PAGE >= all.length} onClick={() => setPage(page + 1)}>
Next
</Button>
</div>
</div>
)}
</>
);
}
function AlbumRow({ pair, g, sel, setSel }: { pair: Pair; g: AlbumGroup; sel: Selection; setSel: (s: Selection) => void }) {
const [open, setOpen] = useState(false);
const picked = g.ids.filter((id) => sel.has(id)).length;
const parts = g.folder ? g.folder.split("/") : [];
const album = parts.length ? parts[parts.length - 1] : "(library root)";
const parent = parts.slice(0, -1).join("/");
const elsewhere = g.master_folders.filter((f) => f !== g.folder);
return (
<div className="border-b border-line last:border-0">
<div className={cx("flex items-center gap-3 px-4 py-2.5 cursor-pointer hover:bg-panel-2/40", picked > 0 && "bg-accent/5")} onClick={() => setOpen(!open)}>
<TriCheckbox label={`Select ${album}`} checked={picked === g.ids.length} partial={picked > 0} onChange={(v) => setSel(toggleIDs(sel, g.ids, v))} />
{open ? <ChevronDown className="size-4 shrink-0 text-muted" /> : <ChevronRight className="size-4 shrink-0 text-muted" />}
<div className="min-w-0 flex-1">
<div className="truncate text-sm">
{parent && <span className="text-muted">{parent} / </span>}
<span className="font-medium">{album}</span>
</div>
{elsewhere.length > 0 && (
<div className="flex min-w-0 items-center gap-1 text-[11px] text-muted" title={elsewhere.join("\n")}>
<ArrowRight className="size-3 shrink-0" />
<span className="truncate">master: {elsewhere.slice(0, 2).join(", ")}{elsewhere.length > 2 && ` +${elsewhere.length - 2}`}</span>
</div>
)}
</div>
<div className="hidden flex-wrap justify-end gap-1 sm:flex">
{g.upgrades > 0 && <Badge tone="green">{g.upgrades} upgrade{g.upgrades > 1 && "s"}</Badge>}
{g.downgrades > 0 && (
<Badge tone="amber">
<AlertTriangle className="size-3" /> {g.downgrades} master lower
</Badge>
)}
{g.identical > 0 && <Badge tone="blue">{g.identical} identical</Badge>}
{g.review - g.upgrades - g.downgrades > 0 && <Badge tone="violet">{g.review - g.upgrades - g.downgrades} to review</Badge>}
{g.approximate > 0 && <Badge>{g.approximate} approximate</Badge>}
{g.other > 0 && <Badge>{g.other} other</Badge>}
</div>
<div className="w-24 shrink-0 text-right text-xs tabular-nums text-muted">
{picked > 0 && picked < g.ids.length ? `${picked}/` : ""}
{g.songs} song{g.songs > 1 && "s"}
<div>{formatBytes(g.bytes)}</div>
</div>
</div>
{open && <AlbumSongs pair={pair} ids={g.ids} sel={sel} setSel={setSel} />}
</div>
);
}
function AlbumSongs({ pair, ids, sel, setSel }: { pair: Pair; ids: number[]; sel: Selection; setSel: (s: Selection) => void }) {
const songs = useQuery({
queryKey: ["matches", "album", pair.id, ids.join(",")],
queryFn: () => api.get<Page<Match>>(`/pairs/${pair.id}/matches?ids=${ids.join(",")}&limit=500`),
});
if (songs.isLoading)
return (
<div className="px-12 py-3">
<Spinner className="size-4" />
</div>
);
return (
<div className="border-t border-line bg-bg/40 pl-6">
{songs.data?.items.map((m) => (
<MatchRow key={m.id} m={m} selected={sel.has(m.id)} onToggle={(v) => setSel(toggleIDs(sel, [m.id], v))} />
))}
</div>
);
}
// ---- bulk bar and confirmation ----
export function BulkBar({ pair, status, sel, clear }: { pair: Pair; status: string; sel: Selection; clear: () => void }) {
const [action, setAction] = useState<BulkAction | null>(null);
if (sel.size === 0) return null;
const running = pair.job?.state === "running";
return (
<>
<div className="sticky bottom-4 z-30 flex flex-wrap items-center gap-2 rounded-xl border border-accent/40 bg-panel/95 px-4 py-3 shadow-2xl backdrop-blur">
<span className="text-sm">
<b className="tabular-nums">{sel.size}</b> song{sel.size > 1 && "s"} selected
</span>
<button className="text-xs text-muted hover:text-white cursor-pointer" onClick={clear}>
<X className="inline size-3" /> clear
</button>
<div className="ml-auto flex flex-wrap gap-2">
{status === "ignored" ? (
<Button size="sm" onClick={() => setAction("unignore")}>
<Eye className="size-3.5" /> Un-ignore
</Button>
) : (
<>
<Button size="sm" variant="ghost" onClick={() => setAction("ignore")}>
<EyeOff className="size-3.5" /> Ignore
</Button>
<Button size="sm" onClick={() => setAction("promote")} disabled={running}>
<Crown className="size-3.5" /> Use slave copies as master…
</Button>
<Button size="sm" variant="primary" onClick={() => setAction("link")} disabled={running}>
<Link2 className="size-3.5" /> Link to master copies…
</Button>
</>
)}
</div>
</div>
{action && <BulkDialog pair={pair} ids={[...sel]} action={action} onClose={() => setAction(null)} onDone={clear} />}
</>
);
}
const titles: Record<BulkAction, string> = {
link: "Link selected songs to the master copies?",
promote: "Replace master files with the selected slave copies?",
ignore: "Ignore selected songs?",
unignore: "Un-ignore selected songs?",
};
const skipHelp: Record<string, string> = {
"master copy is lower quality": "tick the box above to link these anyway",
"master copy is better": "tick the box above to replace these anyway",
"already linked": "nothing to do",
"no master copy": "slave-only songs are never touched",
ignored: "un-ignore them first",
"identical, so link instead": "promoting identical files changes nothing",
};
function BulkDialog({ pair, ids, action, onClose, onDone }: { pair: Pair; ids: number[]; action: BulkAction; onClose: () => void; onDone: () => void }) {
const qc = useQueryClient();
const toast = useToast();
const dry = useInfo().data?.dry_run ?? true;
const [allowDowngrade, setAllowDowngrade] = useState(false);
const [keepFolder, setKeepFolder] = useState(false);
const [ack, setAck] = useState(false);
const body = { ids, action, allow_downgrade: allowDowngrade, keep_folder: keepFolder };
const preview = useQuery({
queryKey: ["bulk-preview", pair.id, action, allowDowngrade, keepFolder, idsKey(ids)],
queryFn: () => api.post<BulkPreview>(`/pairs/${pair.id}/bulk/preview`, body),
gcTime: 0, // always preview the current state of the files
});
const run = useMutation({
mutationFn: () => api.post<{ changed?: number }>(`/pairs/${pair.id}/bulk`, body),
onSuccess: (res) => {
if (action === "ignore" || action === "unignore") toast("ok", `${res.changed} song${res.changed === 1 ? "" : "s"} ${action === "ignore" ? "ignored" : "un-ignored"}`);
else toast("ok", dry ? "Started (dry run): actions are recorded as planned only" : "Started. Progress is shown at the top of the page.");
qc.invalidateQueries({ queryKey: ["matches"] });
qc.invalidateQueries({ queryKey: ["pair", pair.id] });
onDone();
onClose();
},
onError: (e) => toast("err", e.message),
});
const p = preview.data;
const files = action === "link" || action === "promote";
const downgradeSkips = p ? (p.skip[action === "link" ? "master copy is lower quality" : "master copy is better"] ?? 0) : 0;
const needsAck = action === "promote" && !dry;
const verb = { link: "Link", promote: "Replace", ignore: "Ignore", unignore: "Un-ignore" }[action];
return (
<Modal open onClose={onClose} title={titles[action]} wide>
{preview.isLoading && <Spinner />}
{preview.error && <Alert tone="red">{preview.error.message}</Alert>}
{p && (
<div className="space-y-3 text-sm">
<p className="text-gray-300">
<b>{p.selected}</b> song{p.selected !== 1 && "s"} selected in <b>{p.albums}</b> album{p.albums !== 1 && "s"}.{" "}
<b className="text-white">{p.process}</b> will be {action === "promote" ? "promoted" : action === "link" ? "linked" : action + "d"}
{p.process < p.selected && <>, {p.selected - p.process} skipped</>}.
</p>
{files && p.process > 0 && (
<ul className="space-y-1 rounded-lg border border-line bg-panel-2 p-3 text-xs">
{action === "link" && p.identical > 0 && (
<li>
<b>{p.identical}</b> byte-identical: linked without a quarantine copy, freeing about <b>{formatBytes(p.bytes_freed)}</b>.
</li>
)}
{action === "link" && p.quarantined > 0 && (
<li>
<b>{p.quarantined}</b> differ from the master copy: the slave originals go to quarantine and can be restored from History.
</li>
)}
{action === "promote" && (
<li>
<b>{p.quarantined}</b> master file{p.quarantined !== 1 && "s"} will be replaced by the slave copy. The master originals go to quarantine.
</li>
)}
{p.upgrades > 0 && (
<li className="text-emerald-300">
<b>{p.upgrades}</b> {action === "link" ? "upgrade the slave to a better master copy" : "put a better copy into the master library"}.
</li>
)}
</ul>
)}
{files && p.downgrades > 0 && (
<Warn title={`${p.downgrades} will keep the lower-quality copy`} examples={p.examples.downgrades}>
{action === "link" ? "The slave has the better copy for these. It goes to quarantine." : "The master has the better copy for these. It goes to quarantine."}
</Warn>
)}
{files && p.approximate > 0 && (
<Warn title={`${p.approximate} approximate match${p.approximate !== 1 ? "es" : ""}`} examples={p.examples.approximate}>
Found by approximate matching. Expand the albums and spot-check them before continuing.
</Warn>
)}
{files && p.duration_mismatch > 0 && (
<Warn title={`${p.duration_mismatch} with different durations`} examples={p.examples.duration_mismatch}>
The two copies differ by more than 2 seconds, so they may be different versions or edits.
</Warn>
)}
{action === "link" && p.moves_folder > 0 && (
<Warn tone="blue" title={`${p.moves_folder} will move into the master's folders`} examples={p.examples.moves_folder}>
Emptied folders are removed. Playlists that point at the old paths will need updating.
</Warn>
)}
{files && p.renames > 0 && action === "link" && p.moves_folder === 0 && (
<Alert tone="blue">
<b>{p.renames}</b> file name{p.renames !== 1 && "s"} will change to the master's name. Playlists that point at the old names will need updating.
</Alert>
)}
{files && (
<div className="space-y-2">
{(downgradeSkips > 0 || allowDowngrade) && (
<label className="flex items-start gap-2 text-xs cursor-pointer">
<input type="checkbox" className="mt-0.5 accent-amber-500" checked={allowDowngrade} onChange={(e) => setAllowDowngrade(e.target.checked)} />
<span>
Also {action === "link" ? "link" : "promote"} the {downgradeSkips || p.downgrades} song{(downgradeSkips || p.downgrades) !== 1 && "s"} where the{" "}
{action === "link" ? "master" : "slave"} copy is <b>lower quality</b>
</span>
</label>
)}
{action === "link" && (p.moves_folder > 0 || keepFolder) && (
<label className="flex items-center gap-2 text-xs cursor-pointer">
<input type="checkbox" className="accent-violet-500" checked={keepFolder} onChange={(e) => setKeepFolder(e.target.checked)} />
Keep songs in their current slave folders instead
</label>
)}
</div>
)}
{Object.keys(p.skip).length > 0 && (
<div className="text-xs text-muted">
<div className="mb-1 font-medium text-gray-300">Skipped</div>
<ul className="space-y-0.5">
{Object.entries(p.skip).map(([why, n]) => (
<li key={why}>
<b className="tabular-nums text-gray-300">{n}</b> {why}
{skipHelp[why] && <span className="text-gray-500">: {skipHelp[why]}</span>}
</li>
))}
</ul>
</div>
)}
{files && dry && <Alert tone="blue">Dry run is on, so these are recorded as planned in Activity. No files change.</Alert>}
{needsAck && p.process > 0 && (
<label className="flex items-start gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-amber-100 cursor-pointer">
<input type="checkbox" className="mt-0.5 accent-amber-500" checked={ack} onChange={(e) => setAck(e.target.checked)} />
<span>
I understand this <b>changes the master library</b>: {p.process} master file{p.process !== 1 && "s"} will be replaced (originals kept in
quarantine).
</span>
</label>
)}
</div>
)}
<div className="mt-5 flex justify-end gap-2">
<Button variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button
variant={action === "promote" ? "warn" : "primary"}
disabled={!p || p.process === 0 || (needsAck && !ack)}
loading={run.isPending}
onClick={() => run.mutate()}
>
{verb} {p?.process ?? ""} song{p?.process !== 1 && "s"}
</Button>
</div>
</Modal>
);
}
/** A short, order-independent fingerprint of a selection for cache keys. */
function idsKey(ids: number[]) {
let h = 2166136261;
for (const id of [...ids].sort((a, b) => a - b)) h = Math.imul(h ^ id, 16777619) >>> 0;
return `${ids.length}:${h.toString(36)}`;
}
function Warn({ title, children, examples, tone = "amber" }: { title: string; children: ReactNode; examples?: string[]; tone?: "amber" | "blue" }) {
return (
<Alert tone={tone} icon={tone === "amber" ? <AlertTriangle className="mt-0.5 size-4 shrink-0" /> : undefined}>
<div className="font-medium">{title}</div>
<div className="text-xs opacity-90">{children}</div>
{examples && examples.length > 0 && (
<ul className="mt-1 space-y-0.5 text-[11px] opacity-80">
{examples.map((e) => (
<li key={e} className="mono break-all">
{e}
</li>
))}
</ul>
)}
</Alert>
);
}
+68 -4
View File
@@ -1,9 +1,10 @@
import { useEffect, useState } from "react";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowRight, ArrowUpCircle, Download, ChevronDown, ChevronRight, CopyCheck, EyeOff, Eye, Link2, Search, Crown } from "lucide-react";
import { AlertTriangle, ArrowRight, ArrowUpCircle, Disc3, Download, ListMusic, ChevronDown, ChevronRight, CopyCheck, EyeOff, Eye, Link2, Search, Crown } from "lucide-react";
import { ApiError, api, type Match, type Page, type Pair, type ProbeInfo } from "../api";
import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes, formatDuration, inputCls, statusTone } from "../ui";
import { useToast } from "../toast";
import { AlbumView, BulkBar, TriCheckbox, toggleIDs, type Selection } from "./Bulk";
const filters = [
{ id: "review", label: "Needs review" },
@@ -41,12 +42,30 @@ export function Review({ pair }: { pair: Pair }) {
const [debounced, setDebounced] = useState("");
const [page, setPage] = useState(0);
const [confirmBulk, setConfirmBulk] = useState(false);
const [view, setView] = useState<"songs" | "albums">(() => {
try {
return localStorage.getItem("playlink.reviewView") === "albums" ? "albums" : "songs";
} catch {
return "songs";
}
});
const [sel, setSel] = useState<Selection>(new Set());
const chooseView = (v: "songs" | "albums") => {
setView(v);
try {
localStorage.setItem("playlink.reviewView", v);
} catch {
/* private mode: the choice just isn't remembered */
}
};
useEffect(() => {
const t = setTimeout(() => setDebounced(q), 250);
return () => clearTimeout(t);
}, [q]);
useEffect(() => setPage(0), [status, debounced]);
// A selection only makes sense for the list it was made in.
useEffect(() => setSel(new Set()), [status, debounced, pair.id]);
const list = useQuery({
queryKey: ["matches", pair.id, status, debounced, page],
@@ -91,6 +110,20 @@ export function Review({ pair }: { pair: Pair }) {
<Search className="absolute left-2.5 top-2.5 size-4 text-muted" />
<input className={inputCls + " pl-8"} placeholder="Filter by path…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="flex rounded-lg border border-line p-0.5" role="tablist" aria-label="Group by">
{(["songs", "albums"] as const).map((v) => (
<button
key={v}
role="tab"
aria-selected={view === v}
onClick={() => chooseView(v)}
className={cx("flex items-center gap-1 rounded-md px-2.5 py-1 text-xs capitalize cursor-pointer", view === v ? "bg-panel-2 text-white" : "text-muted hover:text-white")}
>
{v === "songs" ? <ListMusic className="size-3.5" /> : <Disc3 className="size-3.5" />}
{v}
</button>
))}
</div>
<ExportMenu pairId={pair.id} status={status} query={debounced} viewCount={list.data?.total} filterLabel={filters.find((f) => f.id === status)?.label ?? "All"} />
</div>
</div>
@@ -107,7 +140,23 @@ export function Review({ pair }: { pair: Pair }) {
</Card>
)}
{view === "albums" ? (
<AlbumView pair={pair} status={status} query={debounced} sel={sel} setSel={setSel} />
) : (
<>
<Card className="overflow-hidden">
{list.data && list.data.items.length > 0 && (
<label className="flex items-center gap-3 border-b border-line bg-panel-2 px-4 py-2 text-xs text-muted">
<TriCheckbox
label="Select all songs on this page"
checked={list.data.items.every((m) => sel.has(m.id))}
partial={list.data.items.some((m) => sel.has(m.id))}
onChange={(v) => setSel(toggleIDs(sel, list.data!.items.map((m) => m.id), v))}
/>
Select all {list.data.items.length} songs on this page
<span className="text-gray-600">· switch to Albums to select whole albums</span>
</label>
)}
{list.isLoading && (
<div className="p-8">
<Spinner />
@@ -118,7 +167,9 @@ export function Review({ pair }: { pair: Pair }) {
{pair.last_scan_at ? "No files match this filter." : "Run a scan to compare the two libraries."}
</Empty>
)}
{list.data?.items.map((m) => <MatchRow key={m.id} m={m} />)}
{list.data?.items.map((m) => (
<MatchRow key={m.id} m={m} selected={sel.has(m.id)} onToggle={(v) => setSel(toggleIDs(sel, [m.id], v))} />
))}
</Card>
{list.data && list.data.total > PAGE && (
@@ -136,6 +187,10 @@ export function Review({ pair }: { pair: Pair }) {
</div>
</div>
)}
</>
)}
<BulkBar pair={pair} status={status} sel={sel} clear={() => setSel(new Set())} />
<Modal open={confirmBulk} onClose={() => setConfirmBulk(false)} title="Link all identical files?">
<p className="text-sm text-gray-300">
@@ -212,7 +267,7 @@ function qualityLabel(p?: ProbeInfo) {
return `${p.codec.toUpperCase()} ${Math.round(p.bitrate / 1000)}k`;
}
function MatchRow({ m }: { m: Match }) {
export function MatchRow({ m, selected, onToggle }: { m: Match; selected?: boolean; onToggle?: (v: boolean) => void }) {
const reviewable = m.status === "different" || m.status === "similar";
const [open, setOpen] = useState(false);
const qc = useQueryClient();
@@ -237,7 +292,16 @@ function MatchRow({ m }: { m: Match }) {
return (
<div className="border-b border-line last:border-0">
<div className="flex items-center gap-3 px-4 py-2.5">
<div className={cx("flex items-center gap-3 px-4 py-2.5", selected && "bg-accent/5")}>
{onToggle && (
<input
type="checkbox"
aria-label={`Select ${m.rel_slave}`}
className="size-4 shrink-0 accent-violet-500 cursor-pointer"
checked={!!selected}
onChange={(e) => onToggle(e.target.checked)}
/>
)}
<button
className={cx("text-muted", reviewable ? "cursor-pointer hover:text-white" : "invisible")}
onClick={() => setOpen(!open)}