1→// AVA — Presets tab: folder/file tree manager (Task 10 redesign). 2→// 3→// Left column: the preset library tree — folders expand/collapse (chevron 4→// only), rows select, double-clicking a preset runs it, the running preset 5→// is flagged emerald, and originals download straight from the row. 6→// Right column: context-sensitive management (load / create / rename / 7→// delete / export .zip / import .zip) plus the club-mode randomizer — the 8→// timer itself lives in the store, this tab only wires the controls. 9→ 10→'use client' 11→ 12→import { useCallback, useEffect, useMemo, useRef, useState } from 'react' 13→import { 14→ ChevronRight, 15→ Download, 16→ FilePlus2, 17→ FileText, 18→ Folder, 19→ FolderOpen, 20→ FolderPlus, 21→ Pencil, 22→ Play, 23→ Trash2, 24→ Upload, 25→} from 'lucide-react' 26→import { 27→ AlertDialog, 28→ AlertDialogAction, 29→ AlertDialogCancel, 30→ AlertDialogContent, 31→ AlertDialogDescription, 32→ AlertDialogFooter, 33→ AlertDialogHeader, 34→ AlertDialogTitle, 35→} from '@/components/ui/alert-dialog' 36→import { Badge } from '@/components/ui/badge' 37→import { Button } from '@/components/ui/button' 38→import { Checkbox } from '@/components/ui/checkbox' 39→import { 40→ Dialog, 41→ DialogContent, 42→ DialogDescription, 43→ DialogFooter, 44→ DialogHeader, 45→ DialogTitle, 46→} from '@/components/ui/dialog' 47→import { Input } from '@/components/ui/input' 48→import { Label } from '@/components/ui/label' 49→import { STARTER_TEMPLATE } from '@/lib/ava/presets' 50→import { refreshPresets, useAvaStore, type PresetItem } from '@/lib/store' 51→import { applyScriptText } from '@/components/ava/audio-source' 52→import { thinScrollbar } from '@/components/ava/shared' 53→import { useToast } from '@/hooks/use-toast' 54→import { cn } from '@/lib/utils' 55→ 56→/** The tree backbone — the backend refuses to rename or delete these. */ 57→const PROTECTED_FOLDERS = new Set(['root', 'root/embedded', 'root/user created']) 58→ 59→/** Same default-expansion rule as the store: paths with ≤ 2 segments open. */ 60→function defaultOpen(path: string): boolean { 61→ return path.split('/').length <= 2 62→} 63→ 64→function plural(n: number, word: string): string { 65→ return `${n} ${word}${n === 1 ? '' : 's'}` 66→} 67→ 68→// --------------------------------------------------------------------------- 69→// tree construction 70→// --------------------------------------------------------------------------- 71→ 72→interface FolderNode { 73→ path: string 74→ name: string 75→ depth: number 76→ children: FolderNode[] 77→ presets: PresetItem[] 78→ /** Presets in this folder and every folder below it. */ 79→ subtreeCount: number 80→} 81→ 82→/** Segment-wise alphabetical compare (a parent always sorts before its children). */ 83→function comparePaths(a: string, b: string): number { 84→ const sa = a.split('/') 85→ const sb = b.split('/') 86→ const shared = Math.min(sa.length, sb.length) 87→ for (let i = 0; i < shared; i++) { 88→ const c = sa[i].toLowerCase().localeCompare(sb[i].toLowerCase()) 89→ if (c !== 0) return c 90→ } 91→ return sa.length - sb.length 92→} 93→ 94→/** 95→ * Build the tree from folder rows + presets. Defensive: a preset whose 96→ * folderPath has no Folder row still gets a node (and its ancestors). 97→ */ 98→function buildTree(folders: string[], presets: PresetItem[]): FolderNode[] { 99→ const pathSet = new Set(folders) 100→ for (const p of presets) { 101→ const segs = p.folderPath.split('/') 102→ for (let i = 1; i <= segs.length; i++) pathSet.add(segs.slice(0, i).join('/')) 103→ } 104→ const paths = [...pathSet].sort(comparePaths) 105→ 106→ const nodes = new Map() 107→ for (const path of paths) { 108→ const segs = path.split('/') 109→ nodes.set(path, { 110→ path, 111→ name: segs[segs.length - 1] || path, 112→ depth: segs.length - 1, 113→ children: [], 114→ presets: [], 115→ subtreeCount: 0, 116→ }) 117→ } 118→ const roots: FolderNode[] = [] 119→ for (const path of paths) { 120→ const node = nodes.get(path) 121→ if (!node) continue 122→ const slash = path.lastIndexOf('/') 123→ const parent = slash >= 0 ? nodes.get(path.slice(0, slash)) : undefined 124→ if (parent) parent.children.push(node) 125→ else roots.push(node) 126→ } 127→ for (const p of presets) nodes.get(p.folderPath)?.presets.push(p) 128→ for (const node of nodes.values()) { 129→ node.presets.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) 130→ } 131→ const countSubtree = (node: FolderNode): number => { 132→ node.subtreeCount = node.presets.length 133→ for (const child of node.children) node.subtreeCount += countSubtree(child) 134→ return node.subtreeCount 135→ } 136→ roots.forEach(countSubtree) 137→ return roots 138→} 139→ 140→// --------------------------------------------------------------------------- 141→// shared load action (Load Preset button + tree double-click) 142→// --------------------------------------------------------------------------- 143→ 144→function useLoadPreset() { 145→ const { toast } = useToast() 146→ return useCallback( 147→ (preset: PresetItem) => { 148→ const result = applyScriptText(preset.content) 149→ const st = useAvaStore.getState() 150→ if (result.ok) { 151→ st.setActivePresetId(preset.id) 152→ st.setEditTarget({ id: preset.id, name: preset.name, folderPath: preset.folderPath }) 153→ toast({ title: 'Preset loaded', description: `"${preset.name}" is now running.` }) 154→ } else if (result.error) { 155→ toast({ 156→ title: 'Preset failed to compile', 157→ description: `[${result.error.section}] line ${result.error.line}: ${result.error.message}`, 158→ variant: 'destructive', 159→ }) 160→ st.setTab('script') 161→ } 162→ }, 163→ [toast], 164→ ) 165→} 166→ 167→// --------------------------------------------------------------------------- 168→// tree rows 169→// --------------------------------------------------------------------------- 170→ 171→function TreeFolder({ node }: { node: FolderNode }) { 172→ const isOpen = useAvaStore((s) => s.expandedFolders[node.path] ?? defaultOpen(node.path)) 173→ const selected = useAvaStore((s) => s.selectedFolder === node.path) 174→ 175→ return ( 176→
177→
188→ 199→ 223→
224→ {isOpen && ( 225→
226→ {node.children.map((child) => ( 227→ 228→ ))} 229→ {node.presets.map((p) => ( 230→ 231→ ))} 232→ {node.children.length === 0 && node.presets.length === 0 && ( 233→

237→ (empty) 238→

239→ )} 240→
241→ )} 242→
243→ ) 244→} 245→ 246→function TreePreset({ preset, depth }: { preset: PresetItem; depth: number }) { 247→ const loadPreset = useLoadPreset() 248→ const selected = useAvaStore((s) => s.selectedPresetId === preset.id) 249→ const running = useAvaStore((s) => s.activePresetId === preset.id) 250→ 251→ return ( 252→
266→ 290→ {preset.originalFile && ( 291→ 298→ 299→ 300→ )} 301→
302→ ) 303→} 304→ 305→// --------------------------------------------------------------------------- 306→// small rename/create dialog shared by the three name-requiring actions 307→// --------------------------------------------------------------------------- 308→ 309→/** 310→ * Fully controlled name prompt shared by the three name-requiring actions 311→ * (create preset / new subfolder / rename). The parent owns the input value 312→ * and error so each open starts clean without mount tricks. 313→ */ 314→function NameDialog({ 315→ open, 316→ onOpenChange, 317→ title, 318→ description, 319→ placeholder, 320→ value, 321→ onChange, 322→ error, 323→ onError, 324→ submitLabel, 325→ busy, 326→ onSubmit, 327→}: { 328→ open: boolean 329→ onOpenChange: (open: boolean) => void 330→ title: string 331→ description: string 332→ placeholder: string 333→ value: string 334→ onChange: (value: string) => void 335→ error: string | null 336→ onError: (error: string | null) => void 337→ submitLabel: string 338→ busy: boolean 339→ /** Return true to close the dialog (server errors keep it open). */ 340→ onSubmit: (name: string) => Promise 341→}) { 342→ const submit = async () => { 343→ const trimmed = value.trim() 344→ if (!trimmed) { 345→ onError('Enter a name first.') 346→ return 347→ } 348→ const ok = await onSubmit(trimmed) 349→ if (ok) onOpenChange(false) 350→ } 351→ 352→ return ( 353→ 354→ 355→ 356→ {title} 357→ {description} 358→ 359→
360→ onChange(e.target.value)} 363→ placeholder={placeholder} 364→ autoFocus 365→ aria-label={title} 366→ className="h-9 border-zinc-700 bg-zinc-900 font-mono text-xs" 367→ onFocus={(e) => e.currentTarget.select()} 368→ onKeyDown={(e) => { 369→ if (e.key === 'Enter') void submit() 370→ }} 371→ /> 372→ {error &&

{error}

} 373→
374→ 375→ 378→ 385→ 386→
387→
388→ ) 389→} 390→ 391→// --------------------------------------------------------------------------- 392→// the tab 393→// --------------------------------------------------------------------------- 394→ 395→type DialogKind = 'createPreset' | 'newFolder' | 'rename' | null 396→ 397→interface DeleteTarget { 398→ kind: 'folder' | 'preset' 399→ name: string 400→ /** Folder path (folder delete) or preset id (preset delete). */ 401→ path: string 402→ presetCount: number 403→ folderCount: number 404→} 405→ 406→export function PresetsTab() { 407→ const { toast } = useToast() 408→ const loadPreset = useLoadPreset() 409→ 410→ const presets = useAvaStore((s) => s.presets) 411→ const folders = useAvaStore((s) => s.folders) 412→ const selectedFolder = useAvaStore((s) => s.selectedFolder) 413→ const selectedPresetId = useAvaStore((s) => s.selectedPresetId) 414→ const randomize = useAvaStore((s) => s.randomize) 415→ const randomIntervalSec = useAvaStore((s) => s.randomIntervalSec) 416→ const randomScope = useAvaStore((s) => s.randomScope) 417→ 418→ const [dialog, setDialog] = useState(null) 419→ const [nameValue, setNameValue] = useState('') 420→ const [nameError, setNameError] = useState(null) 421→ const [busy, setBusy] = useState(false) 422→ const [deleteTarget, setDeleteTarget] = useState(null) 423→ const [delayText, setDelayText] = useState(String(randomIntervalSec)) 424→ const [delayError, setDelayError] = useState(false) 425→ const importInputRef = useRef(null) 426→ 427→ // freshness refresh whenever the tab mounts (initial seed happens on app load) 428→ useEffect(() => { 429→ void refreshPresets() 430→ }, []) 431→ 432→ // keep the delay input in sync when the store value changes elsewhere 433→ useEffect(() => { 434→ setDelayText(String(randomIntervalSec)) 435→ }, [randomIntervalSec]) 436→ 437→ const tree = useMemo(() => buildTree(folders, presets), [folders, presets]) 438→ const selectedPreset = useMemo( 439→ () => presets.find((p) => p.id === selectedPresetId) ?? null, 440→ [presets, selectedPresetId], 441→ ) 442→ const folderProtected = PROTECTED_FOLDERS.has(selectedFolder) 443→ const folderName = selectedFolder.split('/').pop() ?? selectedFolder 444→ const folderPrefix = `${selectedFolder}/` 445→ const folderPresetCount = presets.filter( 446→ (p) => p.folderPath === selectedFolder || p.folderPath.startsWith(folderPrefix), 447→ ).length 448→ const childFolderCount = folders.filter( 449→ (f) => f.startsWith(folderPrefix) && !f.slice(folderPrefix.length).includes('/'), 450→ ).length 451→ const scopeCount = presets.filter( 452→ (p) => randomScope === 'root' || p.folderPath === randomScope || p.folderPath.startsWith(`${randomScope}/`), 453→ ).length 454→ const scopeLabel = randomScope === 'root' ? 'all presets' : (randomScope.split('/').pop() ?? randomScope) 455→ 456→ // ----------------------------------------------------------------- 457→ // actions 458→ // ----------------------------------------------------------------- 459→ 460→ const submitCreatePreset = async (name: string): Promise => { 461→ setBusy(true) 462→ try { 463→ const res = await fetch('/api/presets', { 464→ method: 'POST', 465→ headers: { 'Content-Type': 'application/json' }, 466→ body: JSON.stringify({ name, content: STARTER_TEMPLATE, folderPath: selectedFolder }), 467→ }) 468→ const data = (await res.json().catch(() => null)) as { preset?: PresetItem; error?: string } | null 469→ if (!res.ok || !data?.preset) { 470→ toast({ 471→ title: 'Preset not created', 472→ description: data?.error ?? `Server responded ${res.status}.`, 473→ variant: 'destructive', 474→ }) 475→ return false 476→ } 477→ await refreshPresets() 478→ const st = useAvaStore.getState() 479→ st.setEditTarget({ id: data.preset.id, name: data.preset.name, folderPath: data.preset.folderPath }) 480→ st.setScriptText(STARTER_TEMPLATE) 481→ applyScriptText(STARTER_TEMPLATE) 482→ st.setActivePresetId(data.preset.id) 483→ st.selectPreset(data.preset.id) 484→ st.setTab('script') 485→ toast({ 486→ title: 'Preset created', 487→ description: `"${data.preset.name}" created — now edit and Save.`, 488→ }) 489→ return true 490→ } catch { 491→ toast({ 492→ title: 'Preset not created', 493→ description: 'Network error while creating the preset.', 494→ variant: 'destructive', 495→ }) 496→ return false 497→ } finally { 498→ setBusy(false) 499→ } 500→ } 501→ 502→ const submitNewFolder = async (name: string): Promise => { 503→ const path = `${selectedFolder}/${name}` 504→ setBusy(true) 505→ try { 506→ const res = await fetch('/api/folders', { 507→ method: 'POST', 508→ headers: { 'Content-Type': 'application/json' }, 509→ body: JSON.stringify({ path }), 510→ }) 511→ const data = (await res.json().catch(() => null)) as { folder?: { path: string }; error?: string } | null 512→ if (!res.ok || !data?.folder) { 513→ toast({ 514→ title: 'Folder not created', 515→ description: data?.error ?? `Server responded ${res.status}.`, 516→ variant: 'destructive', 517→ }) 518→ return false 519→ } 520→ await refreshPresets() 521→ const st = useAvaStore.getState() 522→ st.toggleFolder(selectedFolder, true) 523→ st.selectFolder(data.folder.path) 524→ toast({ title: 'Folder created', description: `"${name}" added under ${selectedFolder}.` }) 525→ return true 526→ } catch { 527→ toast({ 528→ title: 'Folder not created', 529→ description: 'Network error while creating the folder.', 530→ variant: 'destructive', 531→ }) 532→ return false 533→ } finally { 534→ setBusy(false) 535→ } 536→ } 537→ 538→ const submitRename = async (name: string): Promise => { 539→ setBusy(true) 540→ try { 541→ if (selectedPreset) { 542→ const res = await fetch(`/api/presets/${selectedPreset.id}`, { 543→ method: 'PUT', 544→ headers: { 'Content-Type': 'application/json' }, 545→ body: JSON.stringify({ name }), 546→ }) 547→ const data = (await res.json().catch(() => null)) as { preset?: PresetItem; error?: string } | null 548→ if (!res.ok || !data?.preset) { 549→ toast({ 550→ title: 'Rename failed', 551→ description: data?.error ?? `Server responded ${res.status}.`, 552→ variant: 'destructive', 553→ }) 554→ return false 555→ } 556→ await refreshPresets() 557→ const st = useAvaStore.getState() 558→ if (st.editTarget?.id === selectedPreset.id) { 559→ st.setEditTarget({ 560→ id: data.preset.id, 561→ name: data.preset.name, 562→ folderPath: data.preset.folderPath, 563→ }) 564→ } 565→ toast({ 566→ title: 'Preset renamed', 567→ description: `"${selectedPreset.name}" is now "${data.preset.name}".`, 568→ }) 569→ return true 570→ } 571→ // folder rename — everything below moves with it 572→ const res = await fetch('/api/folders', { 573→ method: 'PATCH', 574→ headers: { 'Content-Type': 'application/json' }, 575→ body: JSON.stringify({ path: selectedFolder, newName: name }), 576→ }) 577→ const data = (await res.json().catch(() => null)) as { to?: string; error?: string } | null 578→ if (!res.ok || !data?.to) { 579→ toast({ 580→ title: 'Rename failed', 581→ description: data?.error ?? `Server responded ${res.status}.`, 582→ variant: 'destructive', 583→ }) 584→ return false 585→ } 586→ await refreshPresets() 587→ const st = useAvaStore.getState() 588→ st.selectFolder(data.to) 589→ if ( 590→ st.editTarget && 591→ (st.editTarget.folderPath === selectedFolder || st.editTarget.folderPath.startsWith(folderPrefix)) 592→ ) { 593→ st.setEditTarget({ 594→ ...st.editTarget, 595→ folderPath: data.to + st.editTarget.folderPath.slice(selectedFolder.length), 596→ }) 597→ } 598→ toast({ title: 'Folder renamed', description: `Now at ${data.to}.` }) 599→ return true 600→ } catch { 601→ toast({ 602→ title: 'Rename failed', 603→ description: 'Network error while renaming.', 604→ variant: 'destructive', 605→ }) 606→ return false 607→ } finally { 608→ setBusy(false) 609→ } 610→ } 611→ 612→ const openDialog = (kind: Exclude, initialName: string) => { 613→ setDialog(kind) 614→ setNameValue(initialName) 615→ setNameError(null) 616→ } 617→ 618→ const openPresetDelete = () => { 619→ if (!selectedPreset) return 620→ setDeleteTarget({ 621→ kind: 'preset', 622→ name: selectedPreset.name, 623→ path: selectedPreset.id, 624→ presetCount: 1, 625→ folderCount: 0, 626→ }) 627→ } 628→ 629→ const openFolderDelete = () => { 630→ const presetCount = presets.filter( 631→ (p) => p.folderPath === selectedFolder || p.folderPath.startsWith(folderPrefix), 632→ ).length 633→ const folderCount = folders.filter((f) => f.startsWith(folderPrefix)).length 634→ setDeleteTarget({ kind: 'folder', name: folderName, path: selectedFolder, presetCount, folderCount }) 635→ } 636→ 637→ const confirmDelete = async () => { 638→ if (!deleteTarget || busy) return 639→ setBusy(true) 640→ try { 641→ const isFolder = deleteTarget.kind === 'folder' 642→ const res = await fetch( 643→ isFolder ? `/api/folders?path=${encodeURIComponent(deleteTarget.path)}` : `/api/presets/${deleteTarget.path}`, 644→ { method: 'DELETE' }, 645→ ) 646→ const data = (await res.json().catch(() => null)) as { deleted?: boolean; error?: string } | null 647→ if (!res.ok) { 648→ toast({ 649→ title: 'Delete failed', 650→ description: data?.error ?? `Server responded ${res.status}.`, 651→ variant: 'destructive', 652→ }) 653→ return 654→ } 655→ await refreshPresets() 656→ toast({ 657→ title: isFolder ? 'Folder deleted' : 'Preset deleted', 658→ description: isFolder 659→ ? `"${deleteTarget.name}" and everything inside it removed.` 660→ : `"${deleteTarget.name}" removed.`, 661→ }) 662→ } catch { 663→ toast({ 664→ title: 'Delete failed', 665→ description: 'Network error while deleting.', 666→ variant: 'destructive', 667→ }) 668→ } finally { 669→ setBusy(false) 670→ setDeleteTarget(null) 671→ } 672→ } 673→ 674→ const importZip = async (file: File) => { 675→ setBusy(true) 676→ try { 677→ const form = new FormData() 678→ form.append('file', file) 679→ form.append('targetPath', selectedFolder) 680→ const res = await fetch('/api/presets/import', { method: 'POST', body: form }) 681→ const data = (await res.json().catch(() => null)) as 682→ | { imported?: number; skipped?: number; foldersCreated?: number; error?: string } 683→ | null 684→ if (!res.ok || !data) { 685→ toast({ 686→ title: 'Import failed', 687→ description: data?.error ?? `Server responded ${res.status}.`, 688→ variant: 'destructive', 689→ }) 690→ return 691→ } 692→ await refreshPresets() 693→ useAvaStore.getState().toggleFolder(selectedFolder, true) 694→ let summary = `Imported ${data.imported ?? 0} presets` 695→ if ((data.skipped ?? 0) > 0) summary += `, ${data.skipped} duplicates skipped` 696→ if ((data.foldersCreated ?? 0) > 0) summary += ` — ${data.foldersCreated} folders created` 697→ toast({ title: 'Import complete', description: `${summary}.` }) 698→ } catch { 699→ toast({ 700→ title: 'Import failed', 701→ description: 'Network error while importing.', 702→ variant: 'destructive', 703→ }) 704→ } finally { 705→ setBusy(false) 706→ } 707→ } 708→ 709→ // ----------------------------------------------------------------- 710→ // randomizer controls (timer runs in the store) 711→ // ----------------------------------------------------------------- 712→ 713→ const onDelayChange = (raw: string) => { 714→ setDelayText(raw) 715→ const result = useAvaStore.getState().setRandomIntervalSec(Number(raw)) 716→ setDelayError(!result.ok) 717→ if (result.ok && result.value !== undefined) setDelayText(String(result.value)) 718→ } 719→ 720→ const onDelayBlur = () => { 721→ // invalid / partial input never persists — revert to the committed value 722→ if (delayError) setDelayText(String(randomIntervalSec)) 723→ } 724→ 725→ // ----------------------------------------------------------------- 726→ // render 727→ // ----------------------------------------------------------------- 728→ 729→ return ( 730→
731→
732→

733→ {presets.length} presets ·{' '} 734→ {folders.length} folders 735→

736→

double-click a preset to run it

737→
738→ 739→
740→ {/* LEFT — folder/file tree */} 741→
742→
747→ {tree.map((node) => ( 748→ 749→ ))} 750→ {presets.length === 0 && ( 751→

Loading presets…

752→ )} 753→
754→
755→ 756→ {/* RIGHT — management panel */} 757→
758→
759→ {selectedPreset ? ( 760→ 761→ ) : ( 762→ 763→ )} 764→
765→

769→ {selectedPreset ? selectedPreset.name : folderName} 770→

771→

775→ {selectedPreset ? selectedPreset.folderPath : selectedFolder} 776→

777→ {!selectedPreset && ( 778→

779→ {plural(folderPresetCount, 'preset')} · {plural(childFolderCount, 'subfolder')} 780→

781→ )} 782→
783→
784→ 785→
786→ {selectedPreset ? ( 787→ <> 788→ 796→
797→ 806→ 815→
816→ 817→ ) : ( 818→ <> 819→ 828→ 837→
838→ 848→ 858→
859→
860→ 871→ 881→
882→ 883→ )} 884→
885→ 886→ {/* randomize (club mode) — timer runs in the store */} 887→
888→
889→ useAvaStore.getState().setRandomize(v === true)} 893→ aria-label="Randomize presets" 894→ className="size-4 data-[state=checked]:border-emerald-400 data-[state=checked]:bg-emerald-400 data-[state=checked]:text-zinc-950" 895→ /> 896→ 899→
900→ onDelayChange(e.target.value)} 903→ onBlur={onDelayBlur} 904→ inputMode="numeric" 905→ aria-label="Random switch delay in seconds" 906→ className="h-8 w-16 border-zinc-700 bg-zinc-900 px-2 text-center font-mono text-xs tabular-nums" 907→ /> 908→ sec 909→
910→
911→ {delayError && ( 912→

Delay must be between 3 and 60 seconds.

913→ )} 914→

915→ Random from: {scopeLabel} 916→ {scopeCount === 0 && — no presets in this folder yet} 917→

918→ {randomize && ( 919→
920→ 921→ 922→ ON · every {randomIntervalSec}s 923→ 924→
925→ )} 926→
927→
928→
929→ 930→ {/* name dialogs */} 931→ setDialog(o ? 'createPreset' : null)} 934→ title="Create preset" 935→ description={`New preset from the starter template inside ${selectedFolder}.`} 936→ placeholder="Preset name" 937→ value={nameValue} 938→ onChange={setNameValue} 939→ error={nameError} 940→ onError={setNameError} 941→ submitLabel="Create" 942→ busy={busy} 943→ onSubmit={submitCreatePreset} 944→ /> 945→ setDialog(o ? 'newFolder' : null)} 948→ title="New subfolder" 949→ description={`Created inside ${selectedFolder}.`} 950→ placeholder="Folder name" 951→ value={nameValue} 952→ onChange={setNameValue} 953→ error={nameError} 954→ onError={setNameError} 955→ submitLabel="Create" 956→ busy={busy} 957→ onSubmit={submitNewFolder} 958→ /> 959→ setDialog(o ? 'rename' : null)} 962→ title={selectedPreset ? 'Rename preset' : 'Rename folder'} 963→ description={ 964→ selectedPreset 965→ ? `Currently "${selectedPreset.name}" in ${selectedPreset.folderPath}.` 966→ : `Currently "${folderName}" — everything inside moves with it.` 967→ } 968→ placeholder="New name" 969→ value={nameValue} 970→ onChange={setNameValue} 971→ error={nameError} 972→ onError={setNameError} 973→ submitLabel="Rename" 974→ busy={busy} 975→ onSubmit={submitRename} 976→ /> 977→ 978→ {/* delete confirmation */} 979→ !o && setDeleteTarget(null)}> 980→ 981→ 982→ 983→ {deleteTarget?.kind === 'folder' 984→ ? `Delete "${deleteTarget.name}"?` 985→ : `Delete preset "${deleteTarget?.name ?? ''}"?`} 986→ 987→ 988→ {deleteTarget?.kind === 'folder' 989→ ? deleteTarget.presetCount > 0 || deleteTarget.folderCount > 0 990→ ? `Delete "${deleteTarget.name}" and everything inside it? This removes ${plural(deleteTarget.presetCount, 'preset')}${deleteTarget.folderCount > 0 ? ` in ${plural(deleteTarget.folderCount, 'folder')}` : ''}. This cannot be undone.` 991→ : `Delete "${deleteTarget.name}"? The folder is empty. This cannot be undone.` 992→ : `Delete preset "${deleteTarget?.name ?? ''}"? This cannot be undone.`} 993→ 994→ 995→ 996→ Cancel 997→ { 1000→ e.preventDefault() 1001→ void confirmDelete() 1002→ }} 1003→ className="h-9 bg-rose-500 text-zinc-50 hover:bg-rose-400" 1004→ > 1005→ {busy ? 'Deleting…' : 'Delete'} 1006→ 1007→ 1008→ 1009→ 1010→ 1011→ {/* hidden zip import picker */} 1012→ { 1020→ const file = e.target.files?.[0] 1021→ if (file) void importZip(file) 1022→ e.target.value = '' 1023→ }} 1024→ /> 1025→
1026→ ) 1027→} 1028→