Jump the Review list to an artist by letter

An A–Z/# bar above the Songs and Albums lists jumps to the first artist
starting with that letter. Songs come from a new /letters endpoint giving
each leading character's offset; the song list now sorts case-insensitively
like the album list so each letter stays together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-10-01 14:28:27 -07:00
1 parent 095e294f4b
commit 00147aaa9e
5 files changed
+160 -16

No files matched your search

+10 -7
View File
@@ -5,7 +5,7 @@ import { api, type AlbumGroup, type BulkAction, type BulkPreview, type Match, ty
import { useInfo } from "../App";
import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes } from "../ui";
import { useToast } from "../toast";
import { MatchRow } from "./Review";
import { LetterBar, MatchRow, letterOf } from "./Review";
export type Selection = Set<number>;
@@ -52,14 +52,14 @@ export function AlbumView({
sel: Selection;
setSel: (s: Selection) => void;
}) {
const [page, setPage] = useState(0);
useEffect(() => setPage(0), [status, query]);
const [start, setStart] = useState(0);
useEffect(() => setStart(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 shown = all.slice(start, start + GROUP_PAGE);
const shownIDs = shown.flatMap((g) => g.ids);
const pageSelected = shownIDs.length > 0 && shownIDs.every((id) => sel.has(id));
@@ -80,6 +80,9 @@ export function AlbumView({
return (
<>
{all.length > GROUP_PAGE && (
<LetterBar starts={all.map((g, i) => ({ letter: g.folder.charAt(0), offset: i }))} current={shown[0] && letterOf(shown[0].folder)} onJump={setStart} />
)}
<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
@@ -102,13 +105,13 @@ export function AlbumView({
{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}
Albums {start + 1}–{Math.min(start + GROUP_PAGE, all.length)} of {all.length}
</span>
<div className="flex gap-2">
<Button size="sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
<Button size="sm" disabled={start === 0} onClick={() => setStart(Math.max(0, start - GROUP_PAGE))}>
Previous
</Button>
<Button size="sm" disabled={(page + 1) * GROUP_PAGE >= all.length} onClick={() => setPage(page + 1)}>
<Button size="sm" disabled={start + GROUP_PAGE >= all.length} onClick={() => setStart(start + GROUP_PAGE)}>
Next
</Button>
</div>
+61 -7
View File
@@ -35,13 +35,58 @@ function hashParam(name: string) {
const PAGE = 50;
const LETTERS = [..."ABCDEFGHIJKLMNOPQRSTUVWXYZ", "#"];
// letterOf buckets a path by its first character: A–Z, or # for anything else.
export function letterOf(path: string) {
const c = path.charAt(0).toUpperCase();
return c >= "A" && c <= "Z" ? c : "#";
}
// LetterBar jumps a sorted list to the first artist starting with a letter.
// starts gives the list offset of each leading character, in list order.
export function LetterBar({ starts, current, onJump }: { starts: { letter: string; offset: number }[]; current?: string; onJump: (offset: number) => void }) {
const first = new Map<string, number>();
for (const s of starts) {
const l = letterOf(s.letter);
if (!first.has(l) || s.offset < first.get(l)!) first.set(l, s.offset);
}
return (
<nav aria-label="Jump to artist by letter" className="flex flex-wrap gap-0.5">
{LETTERS.map((l) => {
const at = first.get(l);
return (
<button
key={l}
disabled={at === undefined}
onClick={() => at !== undefined && onJump(at)}
aria-current={current === l || undefined}
title={at === undefined ? `No artists starting with ${l}` : `Jump to ${l === "#" ? "numbers and symbols" : l}`}
className={cx(
"min-w-7 rounded-md px-1.5 py-1 text-xs font-medium tabular-nums",
at === undefined
? "text-gray-700 cursor-default"
: current === l
? "bg-accent/15 text-white cursor-pointer"
: "text-muted hover:bg-panel-2 hover:text-white cursor-pointer",
)}
>
{l}
</button>
);
})}
</nav>
);
}
export function Review({ pair }: { pair: Pair }) {
const qc = useQueryClient();
const toast = useToast();
const [status, setStatus] = useState(() => hashParam("status") ?? (pair.summary.review > 0 ? "review" : "identical"));
const [q, setQ] = useState("");
const [debounced, setDebounced] = useState("");
const [page, setPage] = useState(0);
// The list starts at an offset rather than a page so a letter jump lands at the top.
const [offset, setOffset] = useState(0);
const [confirmBulk, setConfirmBulk] = useState(false);
const [view, setView] = useState<"songs" | "albums">(() => {
try {
@@ -64,17 +109,23 @@ export function Review({ pair }: { pair: Pair }) {
const t = setTimeout(() => setDebounced(q), 250);
return () => clearTimeout(t);
}, [q]);
useEffect(() => setPage(0), [status, debounced]);
useEffect(() => setOffset(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],
queryKey: ["matches", pair.id, status, debounced, offset],
queryFn: () =>
api.get<Page<Match>>(`/pairs/${pair.id}/matches?status=${status}&q=${encodeURIComponent(debounced)}&limit=${PAGE}&offset=${page * PAGE}`),
api.get<Page<Match>>(`/pairs/${pair.id}/matches?status=${status}&q=${encodeURIComponent(debounced)}&limit=${PAGE}&offset=${offset}`),
placeholderData: keepPreviousData,
enabled: status !== "duplicates",
});
const letters = useQuery({
queryKey: ["matches", "letters", pair.id, status, debounced],
queryFn: () => api.get<{ letter: string; offset: number }[]>(`/pairs/${pair.id}/letters?status=${status}&q=${encodeURIComponent(debounced)}`),
placeholderData: keepPreviousData,
enabled: status !== "duplicates" && view === "songs",
});
const bulk = useMutation({
mutationFn: () => api.post(`/pairs/${pair.id}/link-identical`),
@@ -165,6 +216,9 @@ export function Review({ pair }: { pair: Pair }) {
<AlbumView pair={pair} status={status} query={debounced} sel={sel} setSel={setSel} />
) : (
<>
{list.data && list.data.total > PAGE && letters.data && (
<LetterBar starts={letters.data} current={list.data.items[0] && letterOf(list.data.items[0].rel_slave)} onJump={setOffset} />
)}
<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">
@@ -196,13 +250,13 @@ export function Review({ pair }: { pair: Pair }) {
{list.data && list.data.total > PAGE && (
<div className="flex items-center justify-between text-sm text-muted">
<span>
{page * PAGE + 1}–{Math.min((page + 1) * PAGE, list.data.total)} of {list.data.total}
{offset + 1}–{Math.min(offset + PAGE, list.data.total)} of {list.data.total}
</span>
<div className="flex gap-2">
<Button size="sm" disabled={page === 0} onClick={() => setPage(page - 1)}>
<Button size="sm" disabled={offset === 0} onClick={() => setOffset(Math.max(0, offset - PAGE))}>
Previous
</Button>
<Button size="sm" disabled={(page + 1) * PAGE >= list.data.total} onClick={() => setPage(page + 1)}>
<Button size="sm" disabled={offset + PAGE >= list.data.total} onClick={() => setOffset(offset + PAGE)}>
Next
</Button>
</div>