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:
1 parent
095e294f4b
commit
00147aaa9e
5 files changed
+160
-16
No files matched your search
+10
-7
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user