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→ toast({ title: 'Preset loaded', description: `"${preset.name}" is now running.` }) 171→ } else if (result.error) { 172→ toast({ 173→ title: 'Preset failed to compile', 174→ description: `[${result.error.section}] line ${result.error.line}: ${result.error.message}`, 175→ variant: 'destructive', 176→ }) 177→ st.setTab('script') 178→ } 179→ }, 180→ [toast], 181→ ) 182→} 183→ 184→// --------------------------------------------------------------------------- 185→// tree rows 186→// --------------------------------------------------------------------------- 187→ 188→function TreeFolder({ node }: { node: FolderNode }) { 189→ const isOpen = useAvaStore((s) => s.expandedFolders[node.path] ?? defaultOpen(node.path)) 190→ const selected = useAvaStore((s) => s.selectedFolder === node.path) 191→ const readOnly = node.path === ROOT || isEmbeddedPath(node.path) 192→ 193→ return ( 194→
195→
206→ 217→ 250→
251→ {isOpen && ( 252→
253→ {node.children.map((child) => ( 254→ 255→ ))} 256→ {node.presets.map((p) => ( 257→ 258→ ))} 259→ {node.children.length === 0 && node.presets.length === 0 && ( 260→

267→ {node.path === USER_ROOT 268→ ? 'yours — create or import presets (stored in this browser)' 269→ : '(empty)'} 270→

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

{error}

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

749→ {presets.length} presets ·{' '} 750→ {folders.length} folders 751→

752→

double-click a preset to run it

753→
754→ 755→
756→ {/* LEFT — folder/file tree */} 757→
758→
763→ {tree.map((node) => ( 764→ 765→ ))} 766→ {presets.length === 0 && ( 767→

Loading presets…

768→ )} 769→
770→
771→ 772→ {/* RIGHT — management panel */} 773→
774→
775→ {selectedPreset ? ( 776→ 777→ ) : ( 778→ 779→ )} 780→
781→

785→ {selectedPreset ? selectedPreset.name : folderName} 786→

787→

791→ {selectedPreset ? selectedPreset.folderPath : selectedFolder} 792→

793→ {!selectedPreset && ( 794→

795→ {plural(folderPresetCount, 'preset')} · {plural(childFolderCount, 'subfolder')} 796→

797→ )} 798→
799→ {(folderReadOnly || (selectedPreset && !presetOwn)) && ( 800→ 804→ read-only 805→ 806→ )} 807→
808→ 809→
810→ {selectedPreset ? ( 811→ <> 812→ 820→ {presetOwn ? ( 821→
822→ 831→ 840→
841→ ) : ( 842→ <> 843→ 852→

853→ Embedded presets are read-only — identical for every visitor. Copy it to make it yours 854→ (stored in this browser only). 855→

856→ 857→ )} 858→ 859→ ) : folderReadOnly ? ( 860→ <> 861→

862→ The embedded library is read-only — the same for every 863→ visitor, safe from edits, renames and deletion. Your own presets live under{' '} 864→ user created, stored in this browser. 865→

866→
867→ 877→ 887→
888→ 889→ ) : ( 890→ <> 891→ 900→ 909→
910→ 920→ 930→
931→
932→ 942→ 952→
953→ 954→ )} 955→
956→ 957→ {/* randomize (club mode) — timer runs in the store */} 958→
959→
960→ useAvaStore.getState().setRandomize(v === true)} 964→ aria-label="Randomize presets" 965→ className="size-4 data-[state=checked]:border-emerald-400 data-[state=checked]:bg-emerald-400 data-[state=checked]:text-zinc-950" 966→ /> 967→ 970→
971→ onDelayChange(e.target.value)} 974→ onBlur={onDelayBlur} 975→ inputMode="numeric" 976→ aria-label="Random switch delay in seconds" 977→ className="h-8 w-16 border-zinc-700 bg-zinc-900 px-2 text-center font-mono text-xs tabular-nums" 978→ /> 979→ sec 980→
981→
982→ {delayError && ( 983→

Delay must be between 3 and 60 seconds.

984→ )} 985→

986→ Random from: {scopeLabel} 987→ {scopeCount === 0 && — no presets in this folder yet} 988→

989→ {randomize && ( 990→
991→ 992→ 993→ ON · every {randomIntervalSec}s 994→ 995→
996→ )} 997→
998→
999→
1000→ 1001→ {/* name dialogs */} 1002→ setDialog(o ? 'createPreset' : null)} 1005→ title="Create preset" 1006→ description={`New preset from the starter template inside ${selectedFolder} — saved in this browser.`} 1007→ placeholder="Preset name" 1008→ value={nameValue} 1009→ onChange={setNameValue} 1010→ error={nameError} 1011→ onError={setNameError} 1012→ submitLabel="Create" 1013→ busy={busy} 1014→ onSubmit={submitCreatePreset} 1015→ /> 1016→ setDialog(o ? 'newFolder' : null)} 1019→ title="New subfolder" 1020→ description={`Created inside ${selectedFolder} — saved in this browser.`} 1021→ placeholder="Folder name" 1022→ value={nameValue} 1023→ onChange={setNameValue} 1024→ error={nameError} 1025→ onError={setNameError} 1026→ submitLabel="Create" 1027→ busy={busy} 1028→ onSubmit={submitNewFolder} 1029→ /> 1030→ setDialog(o ? 'rename' : null)} 1033→ title={selectedPreset ? 'Rename preset' : 'Rename folder'} 1034→ description={ 1035→ selectedPreset 1036→ ? `Currently "${selectedPreset.name}" in ${selectedPreset.folderPath}.` 1037→ : `Currently "${folderName}" — everything inside moves with it.` 1038→ } 1039→ placeholder="New name" 1040→ value={nameValue} 1041→ onChange={setNameValue} 1042→ error={nameError} 1043→ onError={setNameError} 1044→ submitLabel="Rename" 1045→ busy={busy} 1046→ onSubmit={submitRename} 1047→ /> 1048→ 1049→ {/* delete confirmation */} 1050→ !o && setDeleteTarget(null)}> 1051→ 1052→ 1053→ 1054→ {deleteTarget?.kind === 'folder' 1055→ ? `Delete "${deleteTarget.name}"?` 1056→ : `Delete preset "${deleteTarget?.name ?? ''}"?`} 1057→ 1058→ 1059→ {deleteTarget?.kind === 'folder' 1060→ ? deleteTarget.presetCount > 0 || deleteTarget.folderCount > 0 1061→ ? `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.` 1062→ : `Delete "${deleteTarget.name}"? The folder is empty. This cannot be undone.` 1063→ : `Delete preset "${deleteTarget?.name ?? ''}" from this browser? This cannot be undone.`} 1064→ 1065→ 1066→ 1067→ Cancel 1068→ { 1071→ e.preventDefault() 1072→ void confirmDelete() 1073→ }} 1074→ className="h-9 bg-rose-500 text-zinc-50 hover:bg-rose-400" 1075→ > 1076→ {busy ? 'Deleting…' : 'Delete'} 1077→ 1078→ 1079→ 1080→ 1081→ 1082→ {/* hidden zip import picker */} 1083→ { 1091→ const file = e.target.files?.[0] 1092→ if (file) void importZip(file) 1093→ e.target.value = '' 1094→ }} 1095→ /> 1096→
1097→ ) 1098→} 1099→