openvisengine/tool-results/read_1793817160288_8d6de58d6d8d.txt
2026-11-04 18:48:09 +00:00

1106 lines
No EOL
51 KiB
Text

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<string, FolderNode>()
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→ <div>
197→ <div
198→ role="treeitem"
199→ aria-level={node.depth + 1}
200→ aria-expanded={isOpen}
201→ aria-selected={selected}
202→ className={cn(
203→ 'flex items-center rounded-md border-l-2 border-transparent pr-0.5',
204→ selected ? 'border-emerald-400 bg-zinc-800/70' : 'hover:bg-zinc-800/40',
205→ )}
206→ style={{ paddingLeft: node.depth * 10 }}
207→ >
208→ <button
209→ type="button"
210→ onClick={() => useAvaStore.getState().toggleFolder(node.path)}
211→ aria-label={isOpen ? `Collapse folder ${node.name}` : `Expand folder ${node.name}`}
212→ className="flex size-9 shrink-0 items-center justify-center rounded-sm text-zinc-500 hover:text-zinc-200"
213→ >
214→ <ChevronRight
215→ className={cn('size-3.5 transition-transform duration-150', isOpen && 'rotate-90')}
216→ aria-hidden
217→ />
218→ </button>
219→ <button
220→ type="button"
221→ onClick={() => useAvaStore.getState().selectFolder(node.path)}
222→ title={readOnly ? `${node.path} -- read-only (shipped with OVE)` : node.path}
223→ className="flex h-9 min-w-0 flex-1 items-center gap-1.5 py-1 pr-1 text-left"
224→ >
225→ {isOpen ? (
226→ <FolderOpen className="size-3.5 shrink-0 text-zinc-400" aria-hidden />
227→ ) : (
228→ <Folder className="size-3.5 shrink-0 text-zinc-400" aria-hidden />
229→ )}
230→ <span
231→ className={cn('truncate text-xs', selected ? 'font-medium text-zinc-100' : 'text-zinc-300')}
232→ >
233→ {node.name}
234→ </span>
235→ {readOnly && (
236→ <span
237→ title="Read-only -- the shipped library is the same for every visitor"
238→ aria-label="Read-only folder"
239→ className="shrink-0 text-zinc-600"
240→ >
241→ <Lock className="size-3" aria-hidden />
242→ </span>
243→ )}
244→ <Badge
245→ variant="outline"
246→ title={`${node.subtreeCount} presets in this folder and below`}
247→ className="ml-auto shrink-0 border-zinc-700/80 bg-zinc-900/60 px-1.5 font-mono text-[9px] tabular-nums text-zinc-500"
248→ >
249→ {node.subtreeCount}
250→ </Badge>
251→ </button>
252→ </div>
253→ {isOpen && (
254→ <div role="group" aria-label={`${node.name} contents`}>
255→ {node.children.map((child) => (
256→ <TreeFolder key={child.path} node={child} />
257→ ))}
258→ {node.presets.map((p) => (
259→ <TreePreset key={p.id} preset={p} depth={node.depth + 1} />
260→ ))}
261→ {node.children.length === 0 && node.presets.length === 0 && (
262→ <p
263→ className={cn(
264→ 'flex h-8 items-center text-[11px] italic',
265→ node.path === USER_ROOT ? 'text-zinc-500' : 'text-zinc-600',
266→ )}
267→ style={{ paddingLeft: (node.depth + 1) * 10 + 18 }}
268→ >
269→ {node.path === USER_ROOT
270→ ? '(Created and imported presets are stored in this browser.)'
271→ : '(empty)'}
272→ </p>
273→ )}
274→ </div>
275→ )}
276→ </div>
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→ <div
288→ role="treeitem"
289→ aria-level={depth + 1}
290→ aria-selected={selected}
291→ className={cn(
292→ 'group flex items-center rounded-md border-l-2 border-transparent pr-0.5',
293→ running
294→ ? 'border-emerald-400 bg-emerald-400/10'
295→ : selected
296→ ? 'border-emerald-400 bg-zinc-800/70'
297→ : 'hover:bg-zinc-800/40',
298→ )}
299→ style={{ paddingLeft: depth * 10 }}
300→ >
301→ <button
302→ type="button"
303→ onClick={() => useAvaStore.getState().selectPreset(preset.id)}
304→ onDoubleClick={() => loadPreset(preset)}
305→ title={`${preset.name} (double-click to run)${own ? ' -- yours (this browser)' : ''}`}
306→ className="flex h-9 min-w-0 flex-1 items-center gap-1.5 py-1 pr-1 text-left"
307→ >
308→ {running ? (
309→ <Play className="size-3 shrink-0 fill-emerald-400 text-emerald-400" aria-hidden />
310→ ) : (
311→ <FileText className="size-3 shrink-0 text-zinc-500" aria-hidden />
312→ )}
313→ <span className={cn('truncate text-xs', running ? 'font-medium text-emerald-300' : 'text-zinc-300')}>
314→ {preset.name}
315→ </span>
316→ {preset.approx && (
317→ <span
318→ title="Approximation -- the original preset relies on effects the script engine can't run, so this is a uniquely generated stand-in"
319→ className="shrink-0 rounded border border-amber-300/30 bg-amber-300/10 px-1 font-mono text-[9px] leading-4 text-amber-300/90"
320→ >
321→ ≈
322→ </span>
323→ )}
324→ {own && (
325→ <span
326→ title="Your preset -- stored in this browser only"
327→ aria-label="Your preset, stored in this browser"
328→ className="shrink-0 text-emerald-400/60"
329→ >
330→ <User className="size-3" aria-hidden />
331→ </span>
332→ )}
333→ </button>
334→ {preset.originalFile && (
335→ <a
336→ href={preset.originalFile}
337→ download
338→ title="Download original Winamp .avs"
339→ aria-label={`Download the original Winamp .avs file for ${preset.name}`}
340→ className="flex size-9 shrink-0 items-center justify-center rounded-sm text-zinc-500 opacity-100 transition-opacity hover:text-zinc-200 sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100"
341→ >
342→ <Download className="size-3" aria-hidden />
343→ </a>
344→ )}
345→ </div>
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<boolean>
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→ <Dialog open={open} onOpenChange={onOpenChange}>
398→ <DialogContent className="sm:max-w-sm">
399→ <DialogHeader>
400→ <DialogTitle>{title}</DialogTitle>
401→ <DialogDescription>{description}</DialogDescription>
402→ </DialogHeader>
403→ <div>
404→ <Input
405→ value={value}
406→ onChange={(e) => 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 && <p className="mt-1.5 text-[11px] text-rose-400">{error}</p>}
417→ </div>
418→ <DialogFooter>
419→ <Button variant="outline" className="h-9 border-zinc-700" onClick={() => onOpenChange(false)}>
420→ Cancel
421→ </Button>
422→ <Button
423→ className="h-9 gap-1.5 bg-emerald-400 text-emerald-950 hover:bg-emerald-300"
424→ disabled={busy}
425→ onClick={() => void submit()}
426→ >
427→ {busy ? 'Working…' : submitLabel}
428→ </Button>
429→ </DialogFooter>
430→ </DialogContent>
431→ </Dialog>
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<DialogKind>(null)
464→ const [nameValue, setNameValue] = useState('')
465→ const [nameError, setNameError] = useState<string | null>(null)
466→ const [busy, setBusy] = useState(false)
467→ const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null)
468→ const [delayText, setDelayText] = useState(String(randomIntervalSec))
469→ const [delayError, setDelayError] = useState(false)
470→ const importInputRef = useRef<HTMLInputElement>(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<boolean> => {
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<boolean> => {
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<boolean> => {
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<DialogKind, null>, 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→ <div className="space-y-3">
750→ <div className="flex items-center justify-between gap-2">
751→ <p className="text-xs text-zinc-500">
752→ <span className="font-mono tabular-nums text-zinc-400">{presets.length}</span> presets ·{' '}
753→ <span className="font-mono tabular-nums text-zinc-400">{folders.length}</span> folders
754→ </p>
755→ <p className="hidden text-[11px] text-zinc-600 sm:block">double-click a preset to run it</p>
756→ </div>
757→
758→ <div className="flex flex-col gap-3 sm:flex-row">
759→ {/* LEFT -- folder/file tree */}
760→ <div className="min-w-0 flex-1 rounded-lg border border-zinc-800/80 bg-zinc-950/40 p-2 sm:w-[58%] sm:shrink-0">
761→ <div
762→ role="tree"
763→ aria-label="Preset library"
764→ className={cn(thinScrollbar, 'max-h-[420px] overflow-y-auto pr-1 lg:max-h-[480px]')}
765→ >
766→ {tree.map((node) => (
767→ <TreeFolder key={node.path} node={node} />
768→ ))}
769→ {presets.length === 0 && (
770→ <p className="px-2 py-3 text-center text-[11px] italic text-zinc-600">Loading presets…</p>
771→ )}
772→ </div>
773→ </div>
774→
775→ {/* RIGHT -- management panel */}
776→ <div className="flex min-w-0 flex-1 flex-col rounded-lg border border-zinc-800/80 bg-zinc-950/40 p-3">
777→ <div className="flex items-start gap-2">
778→ {selectedPreset ? (
779→ <FileText className="mt-0.5 size-4 shrink-0 text-emerald-300/80" aria-hidden />
780→ ) : (
781→ <Folder className="mt-0.5 size-4 shrink-0 text-emerald-300/80" aria-hidden />
782→ )}
783→ <div className="min-w-0 flex-1">
784→ <p
785→ className="truncate text-xs font-medium text-zinc-100"
786→ title={selectedPreset ? selectedPreset.name : selectedFolder}
787→ >
788→ {selectedPreset ? selectedPreset.name : folderName}
789→ </p>
790→ <p
791→ className="truncate font-mono text-[10px] text-zinc-500"
792→ title={selectedPreset ? selectedPreset.folderPath : selectedFolder}
793→ >
794→ {selectedPreset ? selectedPreset.folderPath : selectedFolder}
795→ </p>
796→ {!selectedPreset && (
797→ <p className="mt-0.5 font-mono text-[10px] tabular-nums text-zinc-600">
798→ {plural(folderPresetCount, 'preset')} · {plural(childFolderCount, 'subfolder')}
799→ </p>
800→ )}
801→ </div>
802→ {(folderReadOnly || (selectedPreset && !presetOwn)) && (
803→ <span
804→ className="flex shrink-0 items-center gap-1 rounded-full border border-zinc-700/70 bg-zinc-900/60 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-zinc-500"
805→ title="Read-only -- identical for every visitor. Copy it to 'user created' to remix."
806→ >
807→ <Lock className="size-2.5" aria-hidden /> read-only
808→ </span>
809→ )}
810→ </div>
811→
812→ <div className="mt-3 space-y-2">
813→ {selectedPreset ? (
814→ <>
815→ <Button
816→ size="sm"
817→ onClick={() => loadPreset(selectedPreset)}
818→ className="h-9 w-full gap-1.5 bg-emerald-400 text-emerald-950 hover:bg-emerald-300"
819→ title="Run this preset on the engine and open it in the editor"
820→ >
821→ <Play className="size-3.5" /> Load Preset
822→ </Button>
823→ {presetOwn ? (
824→ <div className="grid grid-cols-2 gap-2">
825→ <Button
826→ size="sm"
827→ variant="outline"
828→ onClick={() => openDialog('rename', selectedPreset.name)}
829→ title="Rename this preset (within its folder)"
830→ className="h-8 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
831→ >
832→ <Pencil className="size-3.5" /> Rename
833→ </Button>
834→ <Button
835→ size="sm"
836→ variant="outline"
837→ onClick={openPresetDelete}
838→ title="Delete this preset from your browser library"
839→ className="h-9 gap-1.5 border-zinc-700 hover:border-rose-500/40 hover:text-rose-300"
840→ >
841→ <Trash2 className="size-3.5" /> Delete
842→ </Button>
843→ </div>
844→ ) : (
845→ <Button
846→ size="sm"
847→ variant="outline"
848→ onClick={() => copyToMyPresets(selectedPreset)}
849→ title="Save an editable copy of this preset into your browser's 'user created' folder"
850→ className="h-9 w-full gap-1.5 border-zinc-700"
851→ >
852→ <CopyPlus className="size-3.5" /> Copy to my presets
853→ </Button>
854→ )}
855→ </>
856→ ) : folderReadOnly ? (
857→ <>
858→ <div className="grid grid-cols-2 gap-2">
859→ <Button
860→ size="sm"
861→ variant="outline"
862→ disabled={busy || folderPresetCount === 0}
863→ onClick={() => void exportZip()}
864→ title="Download this folder (with subfolders) as a .zip preserving the tree -- built in your browser"
865→ className="h-8 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
866→ >
867→ <Download className="size-3.5" /> Export .zip
868→ </Button>
869→ <Button
870→ size="sm"
871→ variant="outline"
872→ disabled={busy}
873→ onClick={() => importInputRef.current?.click()}
874→ title="Import a .zip of .ava presets -- they land in your browser's 'user created' folder"
875→ className="h-8 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
876→ >
877→ <Upload className="size-3.5" /> Import .zip
878→ </Button>
879→ </div>
880→ </>
881→ ) : (
882→ <>
883→ <Button
884→ size="sm"
885→ variant="outline"
886→ onClick={() => openDialog('createPreset', '')}
887→ title={`Create a new preset from the starter template inside ${selectedFolder} (this browser)`}
888→ className="h-9 w-full gap-1.5 border-zinc-700"
889→ >
890→ <FilePlus2 className="size-3.5" /> Create Preset
891→ </Button>
892→ <Button
893→ size="sm"
894→ variant="outline"
895→ onClick={() => openDialog('newFolder', '')}
896→ title={`Create a subfolder inside ${selectedFolder} (this browser)`}
897→ className="h-9 w-full gap-1.5 border-zinc-700"
898→ >
899→ <FolderPlus className="size-3.5" /> New Subfolder
900→ </Button>
901→ <div className="grid grid-cols-2 gap-2">
902→ <Button
903→ size="sm"
904→ variant="outline"
905→ disabled={folderProtected}
906→ onClick={() => openDialog('rename', folderName)}
907→ title={folderProtected ? '"user created" is the root of your library' : `Rename ${folderName} -- everything inside moves with it`}
908→ className="h-8 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
909→ >
910→ <Pencil className="size-3.5" /> Rename
911→ </Button>
912→ <Button
913→ size="sm"
914→ variant="outline"
915→ disabled={folderProtected}
916→ onClick={openFolderDelete}
917→ title={folderProtected ? '"user created" is the root of your library' : `Delete ${folderName} and everything inside it (this browser)`}
918→ className="h-9 gap-1.5 border-zinc-700 hover:border-rose-500/40 hover:text-rose-300"
919→ >
920→ <Trash2 className="size-3.5" /> Delete
921→ </Button>
922→ </div>
923→ <div className="grid grid-cols-2 gap-2">
924→ <Button
925→ size="sm"
926→ variant="outline"
927→ disabled={busy || folderPresetCount === 0}
928→ onClick={() => void exportZip()}
929→ title="Download this folder (with subfolders) as a .zip preserving the tree -- built in your browser"
930→ className="h-8 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
931→ >
932→ <Download className="size-3.5" /> Export .zip
933→ </Button>
934→ <Button
935→ size="sm"
936→ variant="outline"
937→ disabled={busy}
938→ onClick={() => importInputRef.current?.click()}
939→ title={`Import a .zip of .ava presets into ${selectedFolder} -- the tree inside the zip is preserved`}
940→ className="h-8 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
941→ >
942→ <Upload className="size-3.5" /> Import .zip
943→ </Button>
944→ </div>
945→ </>
946→ )}
947→ </div>
948→
949→ {/* preset mixing (club mode) -- Random or Sequence, one timer in the store */}
950→ <div className="mt-4 border-t border-zinc-800/80 pt-3">
951→ <div className="flex flex-wrap items-center gap-x-3 gap-y-2">
952→ <span className="flex items-center gap-2">
953→ <Checkbox
954→ id="ava-randomize"
955→ checked={randomize}
956→ onCheckedChange={(v) => 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→ <Label htmlFor="ava-randomize" className="cursor-pointer text-xs text-zinc-300">
961→ Random
962→ </Label>
963→ </span>
964→ <span className="flex items-center gap-2">
965→ <Checkbox
966→ id="ava-sequence"
967→ checked={sequenceMode}
968→ onCheckedChange={(v) => 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→ <Label htmlFor="ava-sequence" className="cursor-pointer text-xs text-zinc-300">
973→ Sequence
974→ </Label>
975→ </span>
976→ <span className="ml-auto flex items-center gap-1.5">
977→ <Input
978→ value={delayText}
979→ onChange={(e) => 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→ <span className="text-[11px] text-zinc-500">sec</span>
986→ </span>
987→ </div>
988→ {delayError && (
989→ <p className="mt-1.5 text-[11px] text-rose-400">Delay must be between 3 and 60 seconds.</p>
990→ )}
991→ <p className="mt-2 text-[11px] leading-relaxed text-zinc-500">
992→ {sequenceMode ? 'Sequence from:' : 'Random from:'}{' '}
993→ <span className="font-mono text-zinc-400">{scopeLabel}</span>
994→ {scopeCount === 0 && <span className="ml-1 text-amber-300/80">-- no presets in this folder yet</span>}
995→ </p>
996→ {(randomize || sequenceMode) && (
997→ <div className="mt-2">
998→ <span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-400/30 bg-emerald-400/10 px-2 py-0.5 font-mono text-[10px] text-emerald-300">
999→ <span className="size-1.5 animate-pulse rounded-full bg-emerald-400" aria-hidden />
1000→ {sequenceMode ? 'SEQUENCE' : 'RANDOM'} · every {randomIntervalSec}s
1001→ </span>
1002→ </div>
1003→ )}
1004→ </div>
1005→ </div>
1006→ </div>
1007→
1008→ {/* name dialogs */}
1009→ <NameDialog
1010→ open={dialog === 'createPreset'}
1011→ onOpenChange={(o) => 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→ <NameDialog
1024→ open={dialog === 'newFolder'}
1025→ onOpenChange={(o) => 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→ <NameDialog
1038→ open={dialog === 'rename'}
1039→ onOpenChange={(o) => 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→ <AlertDialog open={deleteTarget !== null} onOpenChange={(o) => !o && setDeleteTarget(null)}>
1058→ <AlertDialogContent className="sm:max-w-sm">
1059→ <AlertDialogHeader>
1060→ <AlertDialogTitle>
1061→ {deleteTarget?.kind === 'folder'
1062→ ? `Delete "${deleteTarget.name}"?`
1063→ : `Delete preset "${deleteTarget?.name ?? ''}"?`}
1064→ </AlertDialogTitle>
1065→ <AlertDialogDescription>
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→ </AlertDialogDescription>
1072→ </AlertDialogHeader>
1073→ <AlertDialogFooter>
1074→ <AlertDialogCancel className="h-9 border-zinc-700">Cancel</AlertDialogCancel>
1075→ <AlertDialogAction
1076→ disabled={busy}
1077→ onClick={(e) => {
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→ </AlertDialogAction>
1085→ </AlertDialogFooter>
1086→ </AlertDialogContent>
1087→ </AlertDialog>
1088→
1089→ {/* hidden zip import picker */}
1090→ <input
1091→ ref={importInputRef}
1092→ type="file"
1093→ accept=".zip,application/zip"
1094→ className="hidden"
1095→ tabIndex={-1}
1096→ aria-hidden
1097→ onChange={(e) => {
1098→ const file = e.target.files?.[0]
1099→ if (file) void importZip(file)
1100→ e.target.value = ''
1101→ }}
1102→ />
1103→ </div>
1104→ )
1105→}
1106→