Build: show the master one letter at a time

The letter bar now picks a section like a page on Review instead of
scrolling one long list; select-all covers the shown letter. Drop the
Backspace hint text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-10-01 15:18:42 -07:00
1 parent fd83d81a16
commit 590b8abe08
1 file changed
+31 -12
+31 -12
View File
@@ -1,11 +1,11 @@
import { useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AudioLines, ChevronDown, ChevronRight, ChevronsDownUp, Disc3, Folder, FolderOpen, Link2, Play, Unlink } from "lucide-react"; import { AudioLines, ChevronDown, ChevronRight, ChevronsDownUp, Disc3, Folder, FolderOpen, Link2, Play, Unlink } from "lucide-react";
import { api, type Pair, type TreeEntry } from "../api"; import { api, type Pair, type TreeEntry } from "../api";
import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes } from "../ui"; import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes } from "../ui";
import { useToast } from "../toast"; import { useToast } from "../toast";
import { MiniPlayer, type Track } from "./Player"; import { MiniPlayer, type Track } from "./Player";
import { LetterBar } from "./Review"; import { LetterBar, letterOf } from "./Review";
const rowID = (rel: string) => `build-${rel}`; const rowID = (rel: string) => `build-${rel}`;
@@ -18,6 +18,9 @@ const ancestorIn = (sel: Set<string>, rel: string) => {
export function Build({ pair }: { pair: Pair }) { export function Build({ pair }: { pair: Pair }) {
// Open folders, oldest first, so Backspace closes the last one opened. // Open folders, oldest first, so Backspace closes the last one opened.
const [open, setOpen] = useState<string[]>([]); const [open, setOpen] = useState<string[]>([]);
// The master's top level is shown one letter at a time.
const [letter, setLetter] = useState<string | null>(null);
const listRef = useRef<HTMLDivElement>(null);
const [sel, setSel] = useState<Set<string>>(new Set()); const [sel, setSel] = useState<Set<string>>(new Set());
const [confirm, setConfirm] = useState<null | "build" | "remove">(null); const [confirm, setConfirm] = useState<null | "build" | "remove">(null);
// The mini-player keeps its own copy of the folder it started in. // The mini-player keeps its own copy of the folder it started in.
@@ -51,6 +54,15 @@ export function Build({ pair }: { pair: Pair }) {
} }
return n; return n;
}); });
const toggleAll = (entries: TreeEntry[], on: boolean) =>
setSel((s) => {
const n = new Set(s);
for (const e of entries) {
if (on) for (const x of n) if (x.startsWith(e.rel + "/")) n.delete(x);
on ? n.add(e.rel) : n.delete(e.rel);
}
return n;
});
const toggleOpen = (rel: string) => const toggleOpen = (rel: string) =>
setOpen((o) => (o.includes(rel) ? o.filter((x) => x !== rel && !x.startsWith(rel + "/")) : [...o, rel])); setOpen((o) => (o.includes(rel) ? o.filter((x) => x !== rel && !x.startsWith(rel + "/")) : [...o, rel]));
const play = (siblings: TreeEntry[], rel: string) => { const play = (siblings: TreeEntry[], rel: string) => {
@@ -58,7 +70,11 @@ export function Build({ pair }: { pair: Pair }) {
setPlayer({ queue, index: queue.findIndex((t) => t.rel === rel) }); setPlayer({ queue, index: queue.findIndex((t) => t.rel === rel) });
}; };
const all = tree.data ?? []; const all = tree.data ?? [];
const allSelected = all.length > 0 && all.every((e) => sel.has(e.rel)); // Until a letter is picked, show the first one the bar offers (A–Z, then #).
const has = new Set(all.map((e) => letterOf(e.name)));
const current = letter && has.has(letter) ? letter : [..."ABCDEFGHIJKLMNOPQRSTUVWXYZ#"].find((l) => has.has(l));
const shown = all.filter((e) => letterOf(e.name) === current);
const allSelected = shown.length > 0 && shown.every((e) => sel.has(e.rel));
// Backspace closes the folder opened last, and scrolls back to it. // Backspace closes the folder opened last, and scrolls back to it.
useEffect(() => { useEffect(() => {
@@ -85,14 +101,9 @@ export function Build({ pair }: { pair: Pair }) {
</Alert> </Alert>
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3 text-xs text-muted">
<Button size="sm" variant="ghost" disabled={!open.length} onClick={() => setOpen([])}> <Button size="sm" variant="ghost" disabled={!open.length} onClick={() => setOpen([])}>
<ChevronsDownUp className="size-3.5" /> Collapse all <ChevronsDownUp className="size-3.5" /> Collapse all
</Button> </Button>
<span className="hidden sm:inline">
<kbd className="rounded border border-line px-1 mono">Backspace</kbd> closes the last folder opened
</span>
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{sel.size > 0 && <span className="text-xs text-muted">{sel.size} selected</span>} {sel.size > 0 && <span className="text-xs text-muted">{sel.size} selected</span>}
<Button size="sm" disabled={!sel.size || running} onClick={() => setConfirm("remove")}> <Button size="sm" disabled={!sel.size || running} onClick={() => setConfirm("remove")}>
@@ -108,10 +119,17 @@ export function Build({ pair }: { pair: Pair }) {
<div className="sticky top-14 z-30 -mx-4 bg-bg/90 px-4 py-1.5 backdrop-blur"> <div className="sticky top-14 z-30 -mx-4 bg-bg/90 px-4 py-1.5 backdrop-blur">
<LetterBar <LetterBar
starts={all.map((e, i) => ({ letter: e.name.charAt(0), offset: i }))} starts={all.map((e, i) => ({ letter: e.name.charAt(0), offset: i }))}
onJump={(i) => document.getElementById(rowID(all[i].rel))?.scrollIntoView({ block: "start", behavior: "smooth" })} current={current}
onJump={(i) => {
setLetter(letterOf(all[i].name));
// Start the new section at its top if the old one was scrolled past it.
const top = listRef.current?.getBoundingClientRect().top ?? 0;
if (top < 0) window.scrollBy({ top: top - 120 });
}}
/> />
</div> </div>
)} )}
<div ref={listRef}>
<Card className="overflow-hidden [&>div:last-child]:border-b-0"> <Card className="overflow-hidden [&>div:last-child]:border-b-0">
{tree.isLoading && ( {tree.isLoading && (
<div className="p-8"> <div className="p-8">
@@ -126,17 +144,18 @@ export function Build({ pair }: { pair: Pair }) {
type="checkbox" type="checkbox"
className="" className=""
checked={allSelected} checked={allSelected}
onChange={() => setSel(allSelected ? new Set() : new Set(all.map((e) => e.rel)))} onChange={() => toggleAll(shown, !allSelected)}
/> />
Select everything in the master library Select all {shown.length} in {current}
</label> </label>
)} )}
<TreeRows <TreeRows
entries={all} entries={shown}
depth={0} depth={0}
ctx={{ pairId: pair.id, open, sel, nowPlaying, toggle, toggleOpen, play }} ctx={{ pairId: pair.id, open, sel, nowPlaying, toggle, toggleOpen, play }}
/> />
</Card> </Card>
</div>
<p className="text-xs text-muted">Folder link counts come from the last scan. Song states are live.</p> <p className="text-xs text-muted">Folder link counts come from the last scan. Song states are live.</p>
{player && ( {player && (