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 { 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 && (
|
||||||
|
|||||||
Reference in new issue
Block a user