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:
1 parent
00147aaa9e
commit
fd83d81a16
1 file changed
+154
-55
+154
-55
@@ -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} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user