diff --git a/web/src/pages/Build.tsx b/web/src/pages/Build.tsx index 5aada13..f5a0352 100644 --- a/web/src/pages/Build.tsx +++ b/web/src/pages/Build.tsx @@ -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, 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([]); const [sel, setSel] = useState>(new Set()); const [confirm, setConfirm] = useState(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(`/pairs/${pair.id}/tree?path=${encodeURIComponent(path)}`), + queryKey: ["tree", pair.id, ""], + queryFn: () => api.get(`/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 (
@@ -49,19 +85,14 @@ export function Build({ pair }: { pair: Pair }) {
- +
+ + + Backspace closes the last folder opened + +
{sel.size > 0 && {sel.size} selected}
- + {all.length > 0 && ( +
+ ({ letter: e.name.charAt(0), offset: i }))} + onJump={(i) => document.getElementById(rowID(all[i].rel))?.scrollIntoView({ block: "start", behavior: "smooth" })} + /> +
+ )} + {tree.isLoading && (
@@ -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 )} - {all.map((e) => ( -
- toggle(e.rel)} /> - {e.dir ? ( - - ) : ( - - )} - {e.dir ? ( - - ) : ( - {e.name} - )} - {e.dir && !!e.linked && {e.linked} linked} - {e.state === "linked" && in slave} - {e.state === "conflict" && different file in slave} - {!e.dir && {formatBytes(e.size ?? 0)}} -
- ))} +

Folder link counts come from the last scan. Song states are live.

@@ -164,3 +175,91 @@ export function Build({ pair }: { pair: Pair }) {
); } + +type TreeCtx = { + pairId: number; + open: string[]; + sel: Set; + 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) => ); +} + +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(`/pairs/${ctx.pairId}/tree?path=${encodeURIComponent(e.rel)}`), + enabled: isOpen, + }); + return ( + <> +
+ ctx.toggle(e.rel)} + /> + {e.dir ? ( + + ) : ( + + )} + {e.dir ? ( + + ) : ( + {e.name} + )} + {e.dir && !!e.linked && {e.linked} linked} + {e.state === "linked" && in slave} + {e.state === "conflict" && different file in slave} + {!e.dir && {formatBytes(e.size ?? 0)}} +
+ {isOpen && children.isLoading && ( +
+ Loading… +
+ )} + {isOpen && children.error && ( +
+ {children.error.message} +
+ )} + {isOpen && children.data?.length === 0 && ( +
+ Empty folder +
+ )} + {isOpen && children.data && } + + ); +}