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:
1 parent
fd83d81a16
commit
590b8abe08
1 file changed
+31
-12
+31
-12
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AudioLines, ChevronDown, ChevronRight, ChevronsDownUp, Disc3, Folder, FolderOpen, Link2, Play, Unlink } from "lucide-react";
|
||||
import { api, type Pair, type TreeEntry } from "../api";
|
||||
import { Alert, Badge, Button, Card, Empty, Modal, Spinner, cx, formatBytes } from "../ui";
|
||||
import { useToast } from "../toast";
|
||||
import { MiniPlayer, type Track } from "./Player";
|
||||
import { LetterBar } from "./Review";
|
||||
import { LetterBar, letterOf } from "./Review";
|
||||
|
||||
const rowID = (rel: string) => `build-${rel}`;
|
||||
|
||||
@@ -18,6 +18,9 @@ const ancestorIn = (sel: Set<string>, rel: string) => {
|
||||
export function Build({ pair }: { pair: Pair }) {
|
||||
// Open folders, oldest first, so Backspace closes the last one opened.
|
||||
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 [confirm, setConfirm] = useState<null | "build" | "remove">(null);
|
||||
// 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;
|
||||
});
|
||||
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) =>
|
||||
setOpen((o) => (o.includes(rel) ? o.filter((x) => x !== rel && !x.startsWith(rel + "/")) : [...o, rel]));
|
||||
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) });
|
||||
};
|
||||
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.
|
||||
useEffect(() => {
|
||||
@@ -85,14 +101,9 @@ export function Build({ pair }: { pair: Pair }) {
|
||||
</Alert>
|
||||
|
||||
<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([])}>
|
||||
<ChevronsDownUp className="size-3.5" /> Collapse all
|
||||
</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">
|
||||
{sel.size > 0 && <span className="text-xs text-muted">{sel.size} selected</span>}
|
||||
<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">
|
||||
<LetterBar
|
||||
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 ref={listRef}>
|
||||
<Card className="overflow-hidden [&>div:last-child]:border-b-0">
|
||||
{tree.isLoading && (
|
||||
<div className="p-8">
|
||||
@@ -126,17 +144,18 @@ export function Build({ pair }: { pair: Pair }) {
|
||||
type="checkbox"
|
||||
className=""
|
||||
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>
|
||||
)}
|
||||
<TreeRows
|
||||
entries={all}
|
||||
entries={shown}
|
||||
depth={0}
|
||||
ctx={{ pairId: pair.id, open, sel, nowPlaying, toggle, toggleOpen, play }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
<p className="text-xs text-muted">Folder link counts come from the last scan. Song states are live.</p>
|
||||
|
||||
{player && (
|
||||
|
||||
Reference in new issue
Block a user