1→// OVE — Presets tab: folder/file tree manager (v6.2 security model). 2→// 3→// The tree merges two worlds: 4→// • root/embedded — the shipped library, READ-ONLY for every visitor (lock 5→// badge in the tree; the server refuses all writes). Selecting an embedded 6→// preset offers "Copy to my presets" instead of rename/delete. 7→// • root/user created — this visitor's OWN presets, stored in their browser 8→// (localStorage). Create / rename / delete / import all happen locally — 9→// nothing is sent to the server, so nobody can spam or vandalize what 10→// other visitors see. 11→// 12→// Left column: the tree (chevron expands, row selects, double-click runs, 13→// running preset flagged emerald, originals download from the row). 14→// Right column: context-sensitive management + the club-mode randomizer. 15→ 16→'use client' 17→ 18→import { useCallback, useEffect, useMemo, useRef, useState } from 'react' 19→import { 20→ ChevronRight, 21→ CopyPlus, 22→ Download, 23→ FilePlus2, 24→ FileText, 25→ Folder, 26→ FolderOpen, 27→ FolderPlus, 28→ Lock, 29→ Pencil, 30→ Play, 31→ Trash2, 32→ Upload, 33→ User, 34→} from 'lucide-react' 35→import { 36→ AlertDialog, 37→ AlertDialogAction, 38→ AlertDialogCancel, 39→ AlertDialogContent, 40→ AlertDialogDescription, 41→ AlertDialogFooter, 42→ AlertDialogHeader, 43→ AlertDialogTitle, 44→} from '@/components/ui/alert-dialog' 45→import { Badge } from '@/components/ui/badge' 46→import { Button } from '@/components/ui/button' 47→import { Checkbox } from '@/components/ui/checkbox' 48→import { 49→ Dialog, 50→ DialogContent, 51→ DialogDescription, 52→ DialogFooter, 53→ DialogHeader, 54→ DialogTitle, 55→} from '@/components/ui/dialog' 56→import { Input } from '@/components/ui/input' 57→import { Label } from '@/components/ui/label' 58→import { STARTER_TEMPLATE } from '@/lib/ava/presets' 59→import { applyUserPresets, refreshPresets, useAvaStore, type PresetItem } from '@/lib/store' 60→import { applyScriptText } from '@/components/ava/audio-source' 61→import { thinScrollbar } from '@/components/ava/shared' 62→import { useToast } from '@/hooks/use-toast' 63→import { cn } from '@/lib/utils' 64→import { ROOT, USER_ROOT, isEmbeddedPath, isUserAreaPath } from '@/lib/preset-paths' 65→import { 66→ createUserFolder, 67→ createUserPreset, 68→ deleteUserFolder, 69→ deleteUserPreset, 70→ presetIsLocal, 71→ renameUserFolder, 72→ renameUserPreset, 73→} from '@/lib/ava/user-presets' 74→import { exportPresetsZip, importPresetsZip } from '@/lib/ava/preset-io' 75→ 76→/** Same default-expansion rule as the store: paths with ≤ 2 segments open. */ 77→function defaultOpen(path: string): boolean { 78→ return path.split('/').length <= 2 79→} 80→ 81→function plural(n: number, word: string): string { 82→ return `${n} ${word}${n === 1 ? '' : 's'}` 83→} 84→ 85→// --------------------------------------------------------------------------- 86→// tree construction 87→// --------------------------------------------------------------------------- 88→ 89→interface FolderNode { 90→ path: string 91→ name: string 92→ depth: number 93→ children: FolderNode[] 94→ presets: PresetItem[] 95→ /** Presets in this folder and every folder below it. */ 96→ subtreeCount: number 97→} 98→ 99→/** Segment-wise alphabetical compare (a parent always sorts before its children). */ 100→function comparePaths(a: string, b: string): number { 101→ const sa = a.split('/') 102→ const sb = b.split('/') 103→ const shared = Math.min(sa.length, sb.length) 104→ for (let i = 0; i < shared; i++) { 105→ const c = sa[i].toLowerCase().localeCompare(sb[i].toLowerCase()) 106→ if (c !== 0) return c 107→ } 108→ return sa.length - sb.length 109→} 110→ 111→/** 112→ * Build the tree from folder rows + presets. Defensive: a preset whose 113→ * folderPath has no Folder row still gets a node (and its ancestors). 114→ */ 115→function buildTree(folders: string[], presets: PresetItem[]): FolderNode[] { 116→ const pathSet = new Set(folders) 117→ for (const p of presets) { 118→ const segs = p.folderPath.split('/') 119→ for (let i = 1; i <= segs.length; i++) pathSet.add(segs.slice(0, i).join('/')) 120→ } 121→ const paths = [...pathSet].sort(comparePaths) 122→ 123→ const nodes = new Map() 124→ for (const path of paths) { 125→ const segs = path.split('/') 126→ nodes.set(path, { 127→ path, 128→ name: segs[segs.length - 1] || path, 129→ depth: segs.length - 1, 130→ children: [], 131→ presets: [], 132→ subtreeCount: 0, 133→ }) 134→ } 135→ const roots: FolderNode[] = [] 136→ for (const path of paths) { 137→ const node = nodes.get(path) 138→ if (!node) continue 139→ const slash = path.lastIndexOf('/') 140→ const parent = slash >= 0 ? nodes.get(path.slice(0, slash)) : undefined 141→ if (parent) parent.children.push(node) 142→ else roots.push(node) 143→ } 144→ for (const p of presets) nodes.get(p.folderPath)?.presets.push(p) 145→ for (const node of nodes.values()) { 146→ node.presets.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())) 147→ } 148→ const countSubtree = (node: FolderNode): number => { 149→ node.subtreeCount = node.presets.length 150→ for (const child of node.children) node.subtreeCount += countSubtree(child) 151→ return node.subtreeCount 152→ } 153→ roots.forEach(countSubtree) 154→ return roots 155→} 156→ 157→// --------------------------------------------------------------------------- 158→// shared load action (Load Preset button + tree double-click) 159→// --------------------------------------------------------------------------- 160→ 161→function useLoadPreset() { 162→ const { toast } = useToast() 163→ return useCallback( 164→ (preset: PresetItem) => { 165→ const result = applyScriptText(preset.content) 166→ const st = useAvaStore.getState() 167→ if (result.ok) { 168→ st.setActivePresetId(preset.id) 169→ st.setEditTarget({ id: preset.id, name: preset.name, folderPath: preset.folderPath }) 170→ // No "Preset loaded" toast (v7.3): the footer's SCRIPT: label 171→ // already echoes the running preset — the popup was intrusive and 172→ // only compile errors genuinely need the attention grab. 173→ } else if (result.error) { 174→ toast({ 175→ title: 'Preset failed to compile', 176→ description: `[${result.error.section}] line ${result.error.line}: ${result.error.message}`, 177→ variant: 'destructive', 178→ }) 179→ st.setTab('script') 180→ } 181→ }, 182→ [toast], 183→ ) 184→} 185→ 186→// --------------------------------------------------------------------------- 187→// tree rows 188→// --------------------------------------------------------------------------- 189→ 190→function TreeFolder({ node }: { node: FolderNode }) { 191→ const isOpen = useAvaStore((s) => s.expandedFolders[node.path] ?? defaultOpen(node.path)) 192→ const selected = useAvaStore((s) => s.selectedFolder === node.path) 193→ const readOnly = node.path === ROOT || isEmbeddedPath(node.path) 194→ 195→ return ( 196→
197→
208→ 219→ 252→
253→ {isOpen && ( 254→
255→ {node.children.map((child) => ( 256→ 257→ ))} 258→ {node.presets.map((p) => ( 259→ 260→ ))} 261→ {node.children.length === 0 && node.presets.length === 0 && ( 262→

269→ {node.path === USER_ROOT 270→ ? '(Created and imported presets are stored in this browser.)' 271→ : '(empty)'} 272→

273→ )} 274→
275→ )} 276→
277→ ) 278→} 279→ 280→function TreePreset({ preset, depth }: { preset: PresetItem; depth: number }) { 281→ const loadPreset = useLoadPreset() 282→ const selected = useAvaStore((s) => s.selectedPresetId === preset.id) 283→ const running = useAvaStore((s) => s.activePresetId === preset.id) 284→ const own = presetIsLocal(preset.id) 285→ 286→ return ( 287→
301→ 334→ {preset.originalFile && ( 335→ 342→ 343→ 344→ )} 345→
346→ ) 347→} 348→ 349→// --------------------------------------------------------------------------- 350→// small rename/create dialog shared by the three name-requiring actions 351→// --------------------------------------------------------------------------- 352→ 353→/** 354→ * Fully controlled name prompt shared by the three name-requiring actions 355→ * (create preset / new subfolder / rename). The parent owns the input value 356→ * and error so each open starts clean without mount tricks. 357→ */ 358→function NameDialog({ 359→ open, 360→ onOpenChange, 361→ title, 362→ description, 363→ placeholder, 364→ value, 365→ onChange, 366→ error, 367→ onError, 368→ submitLabel, 369→ busy, 370→ onSubmit, 371→}: { 372→ open: boolean 373→ onOpenChange: (open: boolean) => void 374→ title: string 375→ description: string 376→ placeholder: string 377→ value: string 378→ onChange: (value: string) => void 379→ error: string | null 380→ onError: (error: string | null) => void 381→ submitLabel: string 382→ busy: boolean 383→ /** Return true to close the dialog (errors keep it open). */ 384→ onSubmit: (name: string) => Promise 385→}) { 386→ const submit = async () => { 387→ const trimmed = value.trim() 388→ if (!trimmed) { 389→ onError('Enter a name first.') 390→ return 391→ } 392→ const ok = await onSubmit(trimmed) 393→ if (ok) onOpenChange(false) 394→ } 395→ 396→ return ( 397→ 398→ 399→ 400→ {title} 401→ {description} 402→ 403→
404→ onChange(e.target.value)} 407→ placeholder={placeholder} 408→ autoFocus 409→ aria-label={title} 410→ className="h-9 border-zinc-700 bg-zinc-900 font-mono text-xs" 411→ onFocus={(e) => e.currentTarget.select()} 412→ onKeyDown={(e) => { 413→ if (e.key === 'Enter') void submit() 414→ }} 415→ /> 416→ {error &&

{error}

} 417→
418→ 419→ 422→ 429→ 430→
431→
432→ ) 433→} 434→ 435→// --------------------------------------------------------------------------- 436→// the tab 437→// --------------------------------------------------------------------------- 438→ 439→type DialogKind = 'createPreset' | 'newFolder' | 'rename' | null 440→ 441→interface DeleteTarget { 442→ kind: 'folder' | 'preset' 443→ name: string 444→ /** Folder path (folder delete) or preset id (preset delete). */ 445→ path: string 446→ presetCount: number 447→ folderCount: number 448→} 449→ 450→export function PresetsTab() { 451→ const { toast } = useToast() 452→ const loadPreset = useLoadPreset() 453→ 454→ const presets = useAvaStore((s) => s.presets) 455→ const folders = useAvaStore((s) => s.folders) 456→ const selectedFolder = useAvaStore((s) => s.selectedFolder) 457→ const selectedPresetId = useAvaStore((s) => s.selectedPresetId) 458→ const randomize = useAvaStore((s) => s.randomize) 459→ const sequenceMode = useAvaStore((s) => s.sequenceMode) 460→ const randomIntervalSec = useAvaStore((s) => s.randomIntervalSec) 461→ const randomScope = useAvaStore((s) => s.randomScope) 462→ 463→ const [dialog, setDialog] = useState(null) 464→ const [nameValue, setNameValue] = useState('') 465→ const [nameError, setNameError] = useState(null) 466→ const [busy, setBusy] = useState(false) 467→ const [deleteTarget, setDeleteTarget] = useState(null) 468→ const [delayText, setDelayText] = useState(String(randomIntervalSec)) 469→ const [delayError, setDelayError] = useState(false) 470→ const importInputRef = useRef(null) 471→ 472→ // freshness refresh whenever the tab mounts (initial merge happens on app load) 473→ useEffect(() => { 474→ void refreshPresets() 475→ }, []) 476→ 477→ // keep the delay input in sync when the store value changes elsewhere 478→ useEffect(() => { 479→ setDelayText(String(randomIntervalSec)) 480→ }, [randomIntervalSec]) 481→ 482→ const tree = useMemo(() => buildTree(folders, presets), [folders, presets]) 483→ const selectedPreset = useMemo( 484→ () => presets.find((p) => p.id === selectedPresetId) ?? null, 485→ [presets, selectedPresetId], 486→ ) 487→ 488→ // ---- read-only vs. browser-owned areas -------------------------------- 489→ const folderReadOnly = selectedFolder === ROOT || isEmbeddedPath(selectedFolder) 490→ const folderProtected = selectedFolder === USER_ROOT // the user area root itself 491→ const presetOwn = selectedPreset ? presetIsLocal(selectedPreset.id) : false 492→ 493→ const folderName = selectedFolder.split('/').pop() ?? selectedFolder 494→ const folderPrefix = `${selectedFolder}/` 495→ const folderPresetCount = presets.filter( 496→ (p) => p.folderPath === selectedFolder || p.folderPath.startsWith(folderPrefix), 497→ ).length 498→ const childFolderCount = folders.filter( 499→ (f) => f.startsWith(folderPrefix) && !f.slice(folderPrefix.length).includes('/'), 500→ ).length 501→ const scopeCount = presets.filter( 502→ (p) => randomScope === 'root' || p.folderPath === randomScope || p.folderPath.startsWith(`${randomScope}/`), 503→ ).length 504→ const scopeLabel = randomScope === 'root' ? 'all presets' : (randomScope.split('/').pop() ?? randomScope) 505→ 506→ // ----------------------------------------------------------------- 507→ // actions — all user-area CRUD happens in THIS browser (localStorage) 508→ // ----------------------------------------------------------------- 509→ 510→ const submitCreatePreset = async (name: string): Promise => { 511→ setBusy(true) 512→ try { 513→ const res = createUserPreset({ 514→ name, 515→ content: STARTER_TEMPLATE, 516→ folderPath: selectedFolder, 517→ author: 'you', 518→ }) 519→ if (!res.ok) { 520→ toast({ title: 'Preset not created', description: res.error, variant: 'destructive' }) 521→ return false 522→ } 523→ applyUserPresets() 524→ const st = useAvaStore.getState() 525→ st.setEditTarget({ id: res.preset.id, name: res.preset.name, folderPath: res.preset.folderPath }) 526→ st.setScriptText(STARTER_TEMPLATE) 527→ applyScriptText(STARTER_TEMPLATE) 528→ st.setActivePresetId(res.preset.id) 529→ st.selectPreset(res.preset.id) 530→ st.setTab('script') 531→ toast({ 532→ title: 'Preset created', 533→ description: `"${res.preset.name}" created in this browser -- now edit and Save.`, 534→ }) 535→ return true 536→ } finally { 537→ setBusy(false) 538→ } 539→ } 540→ 541→ const submitNewFolder = async (name: string): Promise => { 542→ const path = `${selectedFolder}/${name}` 543→ setBusy(true) 544→ try { 545→ const res = createUserFolder(path) 546→ if (!res.ok) { 547→ toast({ title: 'Folder not created', description: res.error, variant: 'destructive' }) 548→ return false 549→ } 550→ applyUserPresets() 551→ const st = useAvaStore.getState() 552→ st.toggleFolder(selectedFolder, true) 553→ st.selectFolder(res.path) 554→ toast({ title: 'Folder created', description: `"${name}" added under ${selectedFolder} (this browser).` }) 555→ return true 556→ } finally { 557→ setBusy(false) 558→ } 559→ } 560→ 561→ const submitRename = async (name: string): Promise => { 562→ setBusy(true) 563→ try { 564→ if (selectedPreset) { 565→ const res = renameUserPreset(selectedPreset.id, name) 566→ if (!res.ok) { 567→ toast({ title: 'Rename failed', description: res.error, variant: 'destructive' }) 568→ return false 569→ } 570→ applyUserPresets() 571→ const st = useAvaStore.getState() 572→ if (st.editTarget?.id === selectedPreset.id) { 573→ st.setEditTarget({ 574→ id: res.preset.id, 575→ name: res.preset.name, 576→ folderPath: res.preset.folderPath, 577→ }) 578→ } 579→ toast({ 580→ title: 'Preset renamed', 581→ description: `"${selectedPreset.name}" is now "${res.preset.name}".`, 582→ }) 583→ return true 584→ } 585→ // folder rename — everything below moves with it (browser-side) 586→ const res = renameUserFolder(selectedFolder, name) 587→ if (!res.ok) { 588→ toast({ title: 'Rename failed', description: res.error, variant: 'destructive' }) 589→ return false 590→ } 591→ applyUserPresets() 592→ const st = useAvaStore.getState() 593→ st.selectFolder(res.to) 594→ if ( 595→ st.editTarget && 596→ (st.editTarget.folderPath === selectedFolder || st.editTarget.folderPath.startsWith(folderPrefix)) 597→ ) { 598→ st.setEditTarget({ 599→ ...st.editTarget, 600→ folderPath: res.to + st.editTarget.folderPath.slice(selectedFolder.length), 601→ }) 602→ } 603→ toast({ title: 'Folder renamed', description: `Now at ${res.to}.` }) 604→ return true 605→ } finally { 606→ setBusy(false) 607→ } 608→ } 609→ 610→ const openDialog = (kind: Exclude, initialName: string) => { 611→ setDialog(kind) 612→ setNameValue(initialName) 613→ setNameError(null) 614→ } 615→ 616→ const openPresetDelete = () => { 617→ if (!selectedPreset) return 618→ setDeleteTarget({ 619→ kind: 'preset', 620→ name: selectedPreset.name, 621→ path: selectedPreset.id, 622→ presetCount: 1, 623→ folderCount: 0, 624→ }) 625→ } 626→ 627→ const openFolderDelete = () => { 628→ const presetCount = presets.filter( 629→ (p) => p.folderPath === selectedFolder || p.folderPath.startsWith(folderPrefix), 630→ ).length 631→ const folderCount = folders.filter((f) => f.startsWith(folderPrefix)).length 632→ setDeleteTarget({ kind: 'folder', name: folderName, path: selectedFolder, presetCount, folderCount }) 633→ } 634→ 635→ const confirmDelete = async () => { 636→ if (!deleteTarget || busy) return 637→ setBusy(true) 638→ try { 639→ const isFolder = deleteTarget.kind === 'folder' 640→ const res = isFolder ? deleteUserFolder(deleteTarget.path) : deleteUserPreset(deleteTarget.path) 641→ if (!res.ok) { 642→ toast({ title: 'Delete failed', description: res.error, variant: 'destructive' }) 643→ return 644→ } 645→ applyUserPresets() 646→ toast({ 647→ title: isFolder ? 'Folder deleted' : 'Preset deleted', 648→ description: isFolder 649→ ? `"${deleteTarget.name}" and everything inside it removed from this browser.` 650→ : `"${deleteTarget.name}" removed from this browser.`, 651→ }) 652→ } finally { 653→ setBusy(false) 654→ setDeleteTarget(null) 655→ } 656→ } 657→ 658→ /** Copy an embedded preset into this visitor's browser library. */ 659→ const copyToMyPresets = (preset: PresetItem) => { 660→ const candidates = [preset.name, `${preset.name} (copy)`, `${preset.name} (copy 2)`, `${preset.name} (copy 3)`] 661→ for (const name of candidates) { 662→ const res = createUserPreset({ 663→ name, 664→ content: preset.content, 665→ folderPath: USER_ROOT, 666→ author: preset.author, 667→ description: preset.description, 668→ }) 669→ if (res.ok) { 670→ applyUserPresets() 671→ const st = useAvaStore.getState() 672→ st.toggleFolder(USER_ROOT, true) 673→ st.selectPreset(res.preset.id) 674→ toast({ 675→ title: 'Copied to your presets', 676→ description: `"${res.preset.name}" is now yours to edit -- stored in this browser only.`, 677→ }) 678→ return 679→ } 680→ if (!res.error.includes('already exists')) { 681→ toast({ title: 'Copy failed', description: res.error, variant: 'destructive' }) 682→ return 683→ } 684→ } 685→ toast({ 686→ title: 'Copy failed', 687→ description: 'A preset with that name already exists -- rename it in "user created" first.', 688→ variant: 'destructive', 689→ }) 690→ } 691→ 692→ const exportZip = async () => { 693→ if (busy) return 694→ setBusy(true) 695→ try { 696→ const res = await exportPresetsZip(selectedFolder, presets, folders) 697→ if (res.ok) { 698→ toast({ title: 'Export ready', description: `${plural(res.presets, 'preset')} zipped and downloading.` }) 699→ } else { 700→ toast({ title: 'Export failed', description: res.error, variant: 'destructive' }) 701→ } 702→ } finally { 703→ setBusy(false) 704→ } 705→ } 706→ 707→ const importZip = async (file: File) => { 708→ setBusy(true) 709→ try { 710→ const res = await importPresetsZip(file, selectedFolder) 711→ if (!res.ok) { 712→ toast({ title: 'Import failed', description: res.error, variant: 'destructive' }) 713→ return 714→ } 715→ applyUserPresets() 716→ useAvaStore.getState().toggleFolder(USER_ROOT, true) 717→ let summary = `Imported ${res.imported} presets into your browser library` 718→ if ((res.skipped ?? 0) > 0) summary += `, ${res.skipped} duplicates skipped` 719→ if ((res.foldersCreated ?? 0) > 0) summary += ` -- ${res.foldersCreated} folders created` 720→ toast({ title: 'Import complete', description: `${summary}.` }) 721→ } catch { 722→ toast({ title: 'Import failed', description: 'Could not read that zip.', variant: 'destructive' }) 723→ } finally { 724→ setBusy(false) 725→ } 726→ } 727→ 728→ // ----------------------------------------------------------------- 729→ // randomizer controls (timer runs in the store) 730→ // ----------------------------------------------------------------- 731→ 732→ const onDelayChange = (raw: string) => { 733→ setDelayText(raw) 734→ const result = useAvaStore.getState().setRandomIntervalSec(Number(raw)) 735→ setDelayError(!result.ok) 736→ if (result.ok && result.value !== undefined) setDelayText(String(result.value)) 737→ } 738→ 739→ const onDelayBlur = () => { 740→ // invalid / partial input never persists — revert to the committed value 741→ if (delayError) setDelayText(String(randomIntervalSec)) 742→ } 743→ 744→ // ----------------------------------------------------------------- 745→ // render 746→ // ----------------------------------------------------------------- 747→ 748→ return ( 749→
750→
751→

752→ {presets.length} presets ·{' '} 753→ {folders.length} folders 754→

755→

double-click a preset to run it

756→
757→ 758→
759→ {/* LEFT -- folder/file tree */} 760→
761→
766→ {tree.map((node) => ( 767→ 768→ ))} 769→ {presets.length === 0 && ( 770→

Loading presets…

771→ )} 772→
773→
774→ 775→ {/* RIGHT -- management panel */} 776→
777→
778→ {selectedPreset ? ( 779→ 780→ ) : ( 781→ 782→ )} 783→
784→

788→ {selectedPreset ? selectedPreset.name : folderName} 789→

790→

794→ {selectedPreset ? selectedPreset.folderPath : selectedFolder} 795→

796→ {!selectedPreset && ( 797→

798→ {plural(folderPresetCount, 'preset')} · {plural(childFolderCount, 'subfolder')} 799→

800→ )} 801→
802→ {(folderReadOnly || (selectedPreset && !presetOwn)) && ( 803→ 807→ read-only 808→ 809→ )} 810→
811→ 812→
813→ {selectedPreset ? ( 814→ <> 815→ 823→ {presetOwn ? ( 824→
825→ 834→ 843→
844→ ) : ( 845→ 854→ )} 855→ 856→ ) : folderReadOnly ? ( 857→ <> 858→
859→ 869→ 879→
880→ 881→ ) : ( 882→ <> 883→ 892→ 901→
902→ 912→ 922→
923→
924→ 934→ 944→
945→ 946→ )} 947→
948→ 949→ {/* preset mixing (club mode) -- Random or Sequence, one timer in the store */} 950→
951→
952→ 953→ useAvaStore.getState().setRandomize(v === true)} 957→ aria-label="Random presets" 958→ className="size-4 data-[state=checked]:border-emerald-400 data-[state=checked]:bg-emerald-400 data-[state=checked]:text-emerald-950" 959→ /> 960→ 963→ 964→ 965→ useAvaStore.getState().setSequenceMode(v === true)} 969→ aria-label="Sequence presets" 970→ className="size-4 data-[state=checked]:border-emerald-400 data-[state=checked]:bg-emerald-400 data-[state=checked]:text-emerald-950" 971→ /> 972→ 975→ 976→ 977→ onDelayChange(e.target.value)} 980→ onBlur={onDelayBlur} 981→ inputMode="numeric" 982→ aria-label="Random switch delay in seconds" 983→ className="h-8 w-16 border-zinc-700 bg-zinc-900 px-2 text-center font-mono text-xs tabular-nums" 984→ /> 985→ sec 986→ 987→
988→ {delayError && ( 989→

Delay must be between 3 and 60 seconds.

990→ )} 991→

992→ {sequenceMode ? 'Sequence from:' : 'Random from:'}{' '} 993→ {scopeLabel} 994→ {scopeCount === 0 && -- no presets in this folder yet} 995→

996→ {(randomize || sequenceMode) && ( 997→
998→ 999→ 1000→ {sequenceMode ? 'SEQUENCE' : 'RANDOM'} · every {randomIntervalSec}s 1001→ 1002→
1003→ )} 1004→
1005→
1006→
1007→ 1008→ {/* name dialogs */} 1009→ setDialog(o ? 'createPreset' : null)} 1012→ title="Create preset" 1013→ description={`New preset from the starter template inside ${selectedFolder} -- saved in this browser.`} 1014→ placeholder="Preset name" 1015→ value={nameValue} 1016→ onChange={setNameValue} 1017→ error={nameError} 1018→ onError={setNameError} 1019→ submitLabel="Create" 1020→ busy={busy} 1021→ onSubmit={submitCreatePreset} 1022→ /> 1023→ setDialog(o ? 'newFolder' : null)} 1026→ title="New subfolder" 1027→ description={`Created inside ${selectedFolder} -- saved in this browser.`} 1028→ placeholder="Folder name" 1029→ value={nameValue} 1030→ onChange={setNameValue} 1031→ error={nameError} 1032→ onError={setNameError} 1033→ submitLabel="Create" 1034→ busy={busy} 1035→ onSubmit={submitNewFolder} 1036→ /> 1037→ setDialog(o ? 'rename' : null)} 1040→ title={selectedPreset ? 'Rename preset' : 'Rename folder'} 1041→ description={ 1042→ selectedPreset 1043→ ? `Currently "${selectedPreset.name}" in ${selectedPreset.folderPath}.` 1044→ : `Currently "${folderName}" -- everything inside moves with it.` 1045→ } 1046→ placeholder="New name" 1047→ value={nameValue} 1048→ onChange={setNameValue} 1049→ error={nameError} 1050→ onError={setNameError} 1051→ submitLabel="Rename" 1052→ busy={busy} 1053→ onSubmit={submitRename} 1054→ /> 1055→ 1056→ {/* delete confirmation */} 1057→ !o && setDeleteTarget(null)}> 1058→ 1059→ 1060→ 1061→ {deleteTarget?.kind === 'folder' 1062→ ? `Delete "${deleteTarget.name}"?` 1063→ : `Delete preset "${deleteTarget?.name ?? ''}"?`} 1064→ 1065→ 1066→ {deleteTarget?.kind === 'folder' 1067→ ? deleteTarget.presetCount > 0 || deleteTarget.folderCount > 0 1068→ ? `Delete "${deleteTarget.name}" and everything inside it? This removes ${plural(deleteTarget.presetCount, 'preset')}${deleteTarget.folderCount > 0 ? ` in ${plural(deleteTarget.folderCount, 'folder')}` : ''} from this browser. This cannot be undone.` 1069→ : `Delete "${deleteTarget.name}"? The folder is empty. This cannot be undone.` 1070→ : `Delete preset "${deleteTarget?.name ?? ''}" from this browser? This cannot be undone.`} 1071→ 1072→ 1073→ 1074→ Cancel 1075→ { 1078→ e.preventDefault() 1079→ void confirmDelete() 1080→ }} 1081→ className="h-9 bg-rose-500 text-zinc-50 hover:bg-rose-400" 1082→ > 1083→ {busy ? 'Deleting…' : 'Delete'} 1084→ 1085→ 1086→ 1087→ 1088→ 1089→ {/* hidden zip import picker */} 1090→ { 1098→ const file = e.target.files?.[0] 1099→ if (file) void importZip(file) 1100→ e.target.value = '' 1101→ }} 1102→ /> 1103→
1104→ ) 1105→} 1106→