openvisengine/tool-results/read_1793086294893_e6288764dc88.txt

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