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

237→ 237→ (empty) 238→ 238→

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

{error}

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

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

736→ 736→

double-click a preset to run it

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

Loading presets…

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

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

771→ 771→

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

777→ 777→ {!selectedPreset && ( 778→ 778→

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

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

Delay must be between 3 and 60 seconds.

913→ 913→ )} 914→ 914→

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

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