Build: browse the master as a nested tree with letter jumps

Folders expand in place instead of replacing the list, so the view never
jumps away. Backspace closes the folder opened last, an A–Z/# bar pinned
under the header scrolls to the first artist with that letter, and a
selected folder visibly covers everything inside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-10-01 15:12:11 -07:00
1 parent 00147aaa9e
commit fd83d81a16
1 file changed
+154 -55
+154 -55
View File
@@ -1,13 +1,23 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AudioLines, ChevronRight, Disc3, Folder, 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 { 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";
const rowID = (rel: string) => `build-${rel}`;
// A row's checkbox is implied by a selected ancestor folder.
const ancestorIn = (sel: Set<string>, rel: string) => {
for (let i = rel.lastIndexOf("/"); i > 0; i = rel.lastIndexOf("/", i - 1)) if (sel.has(rel.slice(0, i))) return true;
return false;
};
export function Build({ pair }: { pair: Pair }) {
const [path, setPath] = useState("");
// Open folders, oldest first, so Backspace closes the last one opened.
const [open, setOpen] = useState<string[]>([]);
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.
@@ -16,8 +26,8 @@ export function Build({ pair }: { pair: Pair }) {
const qc = useQueryClient();
const toast = useToast();
const tree = useQuery({
queryKey: ["tree", pair.id, path],
queryFn: () => api.get<TreeEntry[]>(`/pairs/${pair.id}/tree?path=${encodeURIComponent(path)}`),
queryKey: ["tree", pair.id, ""],
queryFn: () => api.get<TreeEntry[]>(`/pairs/${pair.id}/tree?path=`),
});
const run = useMutation({
mutationFn: (kind: "build" | "remove") => api.post(`/pairs/${pair.id}/${kind}`, { paths: [...sel] }),
@@ -30,16 +40,42 @@ export function Build({ pair }: { pair: Pair }) {
});
const running = pair.job?.state === "running";
const crumbs = path ? path.split("/") : [];
const toggle = (rel: string) =>
setSel((s) => {
const n = new Set(s);
n.has(rel) ? n.delete(rel) : n.add(rel);
if (n.has(rel)) n.delete(rel);
else {
// The folder covers everything inside it.
for (const x of n) if (x.startsWith(rel + "/")) n.delete(x);
n.add(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) => {
const queue = siblings.filter((x) => !x.dir).map((x) => ({ rel: x.rel, name: x.name }));
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));
// Backspace closes the folder opened last, and scrolls back to it.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Backspace" || !open.length || e.metaKey || e.ctrlKey || e.altKey) return;
const t = e.target as HTMLElement;
if (t.isContentEditable || t.tagName === "TEXTAREA" || (t.tagName === "INPUT" && (t as HTMLInputElement).type !== "checkbox")) return;
if (document.querySelector("[role=dialog]")) return;
e.preventDefault();
const last = open[open.length - 1];
setOpen(open.filter((x) => x !== last && !x.startsWith(last + "/")));
document.getElementById(rowID(last))?.scrollIntoView({ block: "nearest" });
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
return (
<div className={cx("space-y-4", player && "pb-28")}>
<Alert tone="blue">
@@ -49,19 +85,14 @@ export function Build({ pair }: { pair: Pair }) {
</Alert>
<div className="flex flex-wrap items-center justify-between gap-3">
<nav className="flex min-w-0 flex-wrap items-center gap-1 text-sm">
<button className="text-accent hover:underline cursor-pointer" onClick={() => setPath("")}>
master
</button>
{crumbs.map((c, i) => (
<span key={i} className="flex items-center gap-1">
<ChevronRight className="size-3.5 text-muted" />
<button className="truncate hover:underline cursor-pointer" onClick={() => setPath(crumbs.slice(0, i + 1).join("/"))}>
{c}
</button>
</span>
))}
</nav>
<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")}>
@@ -73,7 +104,15 @@ export function Build({ pair }: { pair: Pair }) {
</div>
</div>
<Card className="overflow-hidden">
{all.length > 0 && (
<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" })}
/>
</div>
)}
<Card className="overflow-hidden [&>div:last-child]:border-b-0">
{tree.isLoading && (
<div className="p-8">
<Spinner />
@@ -89,42 +128,14 @@ export function Build({ pair }: { pair: Pair }) {
checked={allSelected}
onChange={() => setSel(allSelected ? new Set() : new Set(all.map((e) => e.rel)))}
/>
Select all in this folder
Select everything in the master library
</label>
)}
{all.map((e) => (
<div key={e.rel} className={cx("flex items-center gap-3 border-b border-line px-4 py-2 last:border-0", sel.has(e.rel) && "bg-accent/5")}>
<input type="checkbox" className="" checked={sel.has(e.rel)} onChange={() => toggle(e.rel)} />
{e.dir ? (
<Folder className="size-4 shrink-0 text-accent" />
) : (
<button
className={cx(
"grid size-6 shrink-0 place-items-center rounded-full cursor-pointer",
nowPlaying === e.rel ? "brand-gradient text-on-accent" : "text-muted hover:bg-accent/15 hover:text-accent",
)}
title={nowPlaying === e.rel ? "Playing" : "Listen"}
onClick={() => {
const queue = all.filter((x) => !x.dir).map((x) => ({ rel: x.rel, name: x.name }));
setPlayer({ queue, index: queue.findIndex((t) => t.rel === e.rel) });
}}
>
{nowPlaying === e.rel ? <AudioLines className="size-3.5" /> : <Play className="ml-px size-3 fill-current" />}
</button>
)}
{e.dir ? (
<button className="min-w-0 flex-1 truncate text-left text-sm hover:underline cursor-pointer" onClick={() => { setPath(e.rel); setSel(new Set()); }}>
{e.name}
</button>
) : (
<span className={cx("min-w-0 flex-1 truncate text-sm", nowPlaying === e.rel && "font-medium text-accent")}>{e.name}</span>
)}
{e.dir && !!e.linked && <Badge tone="green">{e.linked} linked</Badge>}
{e.state === "linked" && <Badge tone="green">in slave</Badge>}
{e.state === "conflict" && <Badge tone="amber">different file in slave</Badge>}
{!e.dir && <span className="w-20 text-right text-xs tabular-nums text-muted">{formatBytes(e.size ?? 0)}</span>}
</div>
))}
<TreeRows
entries={all}
depth={0}
ctx={{ pairId: pair.id, open, sel, nowPlaying, toggle, toggleOpen, play }}
/>
</Card>
<p className="text-xs text-muted">Folder link counts come from the last scan. Song states are live.</p>
@@ -164,3 +175,91 @@ export function Build({ pair }: { pair: Pair }) {
</div>
);
}
type TreeCtx = {
pairId: number;
open: string[];
sel: Set<string>;
nowPlaying: string | null;
toggle: (rel: string) => void;
toggleOpen: (rel: string) => void;
play: (siblings: TreeEntry[], rel: string) => void;
};
function TreeRows({ entries, depth, ctx }: { entries: TreeEntry[]; depth: number; ctx: TreeCtx }) {
return entries.map((e) => <TreeRow key={e.rel} e={e} siblings={entries} depth={depth} ctx={ctx} />);
}
function TreeRow({ e, siblings, depth, ctx }: { e: TreeEntry; siblings: TreeEntry[]; depth: number; ctx: TreeCtx }) {
const { open, sel, nowPlaying } = ctx;
const isOpen = e.dir && open.includes(e.rel);
const inherited = ancestorIn(sel, e.rel);
const checked = inherited || sel.has(e.rel);
const children = useQuery({
queryKey: ["tree", ctx.pairId, e.rel],
queryFn: () => api.get<TreeEntry[]>(`/pairs/${ctx.pairId}/tree?path=${encodeURIComponent(e.rel)}`),
enabled: isOpen,
});
return (
<>
<div
id={rowID(e.rel)}
className={cx("flex scroll-mt-28 items-center gap-3 border-b border-line py-2 pr-4", checked && "bg-accent/5")}
style={{ paddingLeft: 16 + depth * 24 }}
>
<input
type="checkbox"
className=""
checked={checked}
disabled={inherited}
title={inherited ? "Included with a selected folder" : undefined}
onChange={() => ctx.toggle(e.rel)}
/>
{e.dir ? (
<button className="flex shrink-0 items-center gap-1 text-accent cursor-pointer" onClick={() => ctx.toggleOpen(e.rel)} aria-expanded={isOpen} aria-label={isOpen ? "Collapse" : "Expand"}>
{isOpen ? <ChevronDown className="size-3.5 text-muted" /> : <ChevronRight className="size-3.5 text-muted" />}
{isOpen ? <FolderOpen className="size-4" /> : <Folder className="size-4" />}
</button>
) : (
<button
className={cx(
"ml-[18px] grid size-6 shrink-0 place-items-center rounded-full cursor-pointer",
nowPlaying === e.rel ? "brand-gradient text-on-accent" : "text-muted hover:bg-accent/15 hover:text-accent",
)}
title={nowPlaying === e.rel ? "Playing" : "Listen"}
onClick={() => ctx.play(siblings, e.rel)}
>
{nowPlaying === e.rel ? <AudioLines className="size-3.5" /> : <Play className="ml-px size-3 fill-current" />}
</button>
)}
{e.dir ? (
<button className="min-w-0 flex-1 truncate text-left text-sm hover:underline cursor-pointer" onClick={() => ctx.toggleOpen(e.rel)}>
{e.name}
</button>
) : (
<span className={cx("min-w-0 flex-1 truncate text-sm", nowPlaying === e.rel && "font-medium text-accent")}>{e.name}</span>
)}
{e.dir && !!e.linked && <Badge tone="green">{e.linked} linked</Badge>}
{e.state === "linked" && <Badge tone="green">in slave</Badge>}
{e.state === "conflict" && <Badge tone="amber">different file in slave</Badge>}
{!e.dir && <span className="w-20 text-right text-xs tabular-nums text-muted">{formatBytes(e.size ?? 0)}</span>}
</div>
{isOpen && children.isLoading && (
<div className="border-b border-line py-2 text-xs text-muted" style={{ paddingLeft: 16 + (depth + 1) * 24 }}>
Loading…
</div>
)}
{isOpen && children.error && (
<div className="border-b border-line py-2 pr-4" style={{ paddingLeft: 16 + (depth + 1) * 24 }}>
<Alert tone="red">{children.error.message}</Alert>
</div>
)}
{isOpen && children.data?.length === 0 && (
<div className="border-b border-line py-2 text-xs text-muted" style={{ paddingLeft: 16 + (depth + 1) * 24 }}>
Empty folder
</div>
)}
{isOpen && children.data && <TreeRows entries={children.data} depth={depth + 1} ctx={ctx} />}
</>
);
}