openvisengine/tool-results/read_1793082296314_a82167d4aa24.txt
2026-10-27 06:38:24 +00:00

1028 lines
No EOL
46 KiB
Text

1→// AVA — Presets tab: folder/file tree manager (Task 10 redesign).
2→//
3→// Left column: the preset library tree — folders expand/collapse (chevron
4→// only), rows select, double-clicking a preset runs it, the running preset
5→// is flagged emerald, and originals download straight from the row.
6→// Right column: context-sensitive management (load / create / rename /
7→// delete / export .zip / import .zip) plus the club-mode randomizer — the
8→// timer itself lives in the store, this tab only wires the controls.
9→
10→'use client'
11→
12→import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
13→import {
14→ ChevronRight,
15→ Download,
16→ FilePlus2,
17→ FileText,
18→ Folder,
19→ FolderOpen,
20→ FolderPlus,
21→ Pencil,
22→ Play,
23→ Trash2,
24→ Upload,
25→} from 'lucide-react'
26→import {
27→ AlertDialog,
28→ AlertDialogAction,
29→ AlertDialogCancel,
30→ AlertDialogContent,
31→ AlertDialogDescription,
32→ AlertDialogFooter,
33→ AlertDialogHeader,
34→ AlertDialogTitle,
35→} from '@/components/ui/alert-dialog'
36→import { Badge } from '@/components/ui/badge'
37→import { Button } from '@/components/ui/button'
38→import { Checkbox } from '@/components/ui/checkbox'
39→import {
40→ Dialog,
41→ DialogContent,
42→ DialogDescription,
43→ DialogFooter,
44→ DialogHeader,
45→ DialogTitle,
46→} from '@/components/ui/dialog'
47→import { Input } from '@/components/ui/input'
48→import { Label } from '@/components/ui/label'
49→import { STARTER_TEMPLATE } from '@/lib/ava/presets'
50→import { refreshPresets, useAvaStore, type PresetItem } from '@/lib/store'
51→import { applyScriptText } from '@/components/ava/audio-source'
52→import { thinScrollbar } from '@/components/ava/shared'
53→import { useToast } from '@/hooks/use-toast'
54→import { cn } from '@/lib/utils'
55→
56→/** The tree backbone — the backend refuses to rename or delete these. */
57→const PROTECTED_FOLDERS = new Set(['root', 'root/embedded', 'root/user created'])
58→
59→/** Same default-expansion rule as the store: paths with ≤ 2 segments open. */
60→function defaultOpen(path: string): boolean {
61→ return path.split('/').length <= 2
62→}
63→
64→function plural(n: number, word: string): string {
65→ return `${n} ${word}${n === 1 ? '' : 's'}`
66→}
67→
68→// ---------------------------------------------------------------------------
69→// tree construction
70→// ---------------------------------------------------------------------------
71→
72→interface FolderNode {
73→ path: string
74→ name: string
75→ depth: number
76→ children: FolderNode[]
77→ presets: PresetItem[]
78→ /** Presets in this folder and every folder below it. */
79→ subtreeCount: number
80→}
81→
82→/** Segment-wise alphabetical compare (a parent always sorts before its children). */
83→function comparePaths(a: string, b: string): number {
84→ const sa = a.split('/')
85→ const sb = b.split('/')
86→ const shared = Math.min(sa.length, sb.length)
87→ for (let i = 0; i < shared; i++) {
88→ const c = sa[i].toLowerCase().localeCompare(sb[i].toLowerCase())
89→ if (c !== 0) return c
90→ }
91→ return sa.length - sb.length
92→}
93→
94→/**
95→ * Build the tree from folder rows + presets. Defensive: a preset whose
96→ * folderPath has no Folder row still gets a node (and its ancestors).
97→ */
98→function buildTree(folders: string[], presets: PresetItem[]): FolderNode[] {
99→ const pathSet = new Set(folders)
100→ for (const p of presets) {
101→ const segs = p.folderPath.split('/')
102→ for (let i = 1; i <= segs.length; i++) pathSet.add(segs.slice(0, i).join('/'))
103→ }
104→ const paths = [...pathSet].sort(comparePaths)
105→
106→ const nodes = new Map<string, FolderNode>()
107→ for (const path of paths) {
108→ const segs = path.split('/')
109→ nodes.set(path, {
110→ path,
111→ name: segs[segs.length - 1] || path,
112→ depth: segs.length - 1,
113→ children: [],
114→ presets: [],
115→ subtreeCount: 0,
116→ })
117→ }
118→ const roots: FolderNode[] = []
119→ for (const path of paths) {
120→ const node = nodes.get(path)
121→ if (!node) continue
122→ const slash = path.lastIndexOf('/')
123→ const parent = slash >= 0 ? nodes.get(path.slice(0, slash)) : undefined
124→ if (parent) parent.children.push(node)
125→ else roots.push(node)
126→ }
127→ for (const p of presets) nodes.get(p.folderPath)?.presets.push(p)
128→ for (const node of nodes.values()) {
129→ node.presets.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
130→ }
131→ const countSubtree = (node: FolderNode): number => {
132→ node.subtreeCount = node.presets.length
133→ for (const child of node.children) node.subtreeCount += countSubtree(child)
134→ return node.subtreeCount
135→ }
136→ roots.forEach(countSubtree)
137→ return roots
138→}
139→
140→// ---------------------------------------------------------------------------
141→// shared load action (Load Preset button + tree double-click)
142→// ---------------------------------------------------------------------------
143→
144→function useLoadPreset() {
145→ const { toast } = useToast()
146→ return useCallback(
147→ (preset: PresetItem) => {
148→ const result = applyScriptText(preset.content)
149→ const st = useAvaStore.getState()
150→ if (result.ok) {
151→ st.setActivePresetId(preset.id)
152→ st.setEditTarget({ id: preset.id, name: preset.name, folderPath: preset.folderPath })
153→ toast({ title: 'Preset loaded', description: `"${preset.name}" is now running.` })
154→ } else if (result.error) {
155→ toast({
156→ title: 'Preset failed to compile',
157→ description: `[${result.error.section}] line ${result.error.line}: ${result.error.message}`,
158→ variant: 'destructive',
159→ })
160→ st.setTab('script')
161→ }
162→ },
163→ [toast],
164→ )
165→}
166→
167→// ---------------------------------------------------------------------------
168→// tree rows
169→// ---------------------------------------------------------------------------
170→
171→function TreeFolder({ node }: { node: FolderNode }) {
172→ const isOpen = useAvaStore((s) => s.expandedFolders[node.path] ?? defaultOpen(node.path))
173→ const selected = useAvaStore((s) => s.selectedFolder === node.path)
174→
175→ return (
176→ <div>
177→ <div
178→ role="treeitem"
179→ aria-level={node.depth + 1}
180→ aria-expanded={isOpen}
181→ aria-selected={selected}
182→ className={cn(
183→ 'flex items-center rounded-md border-l-2 border-transparent pr-0.5',
184→ selected ? 'border-emerald-400 bg-zinc-800/70' : 'hover:bg-zinc-800/40',
185→ )}
186→ style={{ paddingLeft: node.depth * 10 }}
187→ >
188→ <button
189→ type="button"
190→ onClick={() => useAvaStore.getState().toggleFolder(node.path)}
191→ aria-label={isOpen ? `Collapse folder ${node.name}` : `Expand folder ${node.name}`}
192→ className="flex size-9 shrink-0 items-center justify-center rounded-sm text-zinc-500 hover:text-zinc-200"
193→ >
194→ <ChevronRight
195→ className={cn('size-3.5 transition-transform duration-150', isOpen && 'rotate-90')}
196→ aria-hidden
197→ />
198→ </button>
199→ <button
200→ type="button"
201→ onClick={() => useAvaStore.getState().selectFolder(node.path)}
202→ title={node.path}
203→ className="flex h-9 min-w-0 flex-1 items-center gap-1.5 py-1 pr-1 text-left"
204→ >
205→ {isOpen ? (
206→ <FolderOpen className="size-3.5 shrink-0 text-zinc-400" aria-hidden />
207→ ) : (
208→ <Folder className="size-3.5 shrink-0 text-zinc-400" aria-hidden />
209→ )}
210→ <span
211→ className={cn('truncate text-xs', selected ? 'font-medium text-zinc-100' : 'text-zinc-300')}
212→ >
213→ {node.name}
214→ </span>
215→ <Badge
216→ variant="outline"
217→ title={`${node.subtreeCount} presets in this folder and below`}
218→ 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"
219→ >
220→ {node.subtreeCount}
221→ </Badge>
222→ </button>
223→ </div>
224→ {isOpen && (
225→ <div role="group" aria-label={`${node.name} contents`}>
226→ {node.children.map((child) => (
227→ <TreeFolder key={child.path} node={child} />
228→ ))}
229→ {node.presets.map((p) => (
230→ <TreePreset key={p.id} preset={p} depth={node.depth + 1} />
231→ ))}
232→ {node.children.length === 0 && node.presets.length === 0 && (
233→ <p
234→ className="flex h-8 items-center text-[11px] italic text-zinc-600"
235→ style={{ paddingLeft: (node.depth + 1) * 10 + 18 }}
236→ >
237→ (empty)
238→ </p>
239→ )}
240→ </div>
241→ )}
242→ </div>
243→ )
244→}
245→
246→function TreePreset({ preset, depth }: { preset: PresetItem; depth: number }) {
247→ const loadPreset = useLoadPreset()
248→ const selected = useAvaStore((s) => s.selectedPresetId === preset.id)
249→ const running = useAvaStore((s) => s.activePresetId === preset.id)
250→
251→ return (
252→ <div
253→ role="treeitem"
254→ aria-level={depth + 1}
255→ aria-selected={selected}
256→ className={cn(
257→ 'group flex items-center rounded-md border-l-2 border-transparent pr-0.5',
258→ running
259→ ? 'border-emerald-400 bg-emerald-400/10'
260→ : selected
261→ ? 'border-emerald-400 bg-zinc-800/70'
262→ : 'hover:bg-zinc-800/40',
263→ )}
264→ style={{ paddingLeft: depth * 10 }}
265→ >
266→ <button
267→ type="button"
268→ onClick={() => useAvaStore.getState().selectPreset(preset.id)}
269→ onDoubleClick={() => loadPreset(preset)}
270→ title={`${preset.name} (double-click to run)`}
271→ className="flex h-9 min-w-0 flex-1 items-center gap-1.5 py-1 pr-1 text-left"
272→ >
273→ {running ? (
274→ <Play className="size-3 shrink-0 fill-emerald-400 text-emerald-400" aria-hidden />
275→ ) : (
276→ <FileText className="size-3 shrink-0 text-zinc-500" aria-hidden />
277→ )}
278→ <span className={cn('truncate text-xs', running ? 'font-medium text-emerald-300' : 'text-zinc-300')}>
279→ {preset.name}
280→ </span>
281→ {preset.approx && (
282→ <span
283→ title="Approximation — the original preset relies on effects the script engine can't run, so this is a uniquely generated stand-in"
284→ 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"
285→ >
286→ ≈
287→ </span>
288→ )}
289→ </button>
290→ {preset.originalFile && (
291→ <a
292→ href={preset.originalFile}
293→ download
294→ title="Download original Winamp .avs"
295→ aria-label={`Download the original Winamp .avs file for ${preset.name}`}
296→ 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"
297→ >
298→ <Download className="size-3" aria-hidden />
299→ </a>
300→ )}
301→ </div>
302→ )
303→}
304→
305→// ---------------------------------------------------------------------------
306→// small rename/create dialog shared by the three name-requiring actions
307→// ---------------------------------------------------------------------------
308→
309→/**
310→ * Fully controlled name prompt shared by the three name-requiring actions
311→ * (create preset / new subfolder / rename). The parent owns the input value
312→ * and error so each open starts clean without mount tricks.
313→ */
314→function NameDialog({
315→ open,
316→ onOpenChange,
317→ title,
318→ description,
319→ placeholder,
320→ value,
321→ onChange,
322→ error,
323→ onError,
324→ submitLabel,
325→ busy,
326→ onSubmit,
327→}: {
328→ open: boolean
329→ onOpenChange: (open: boolean) => void
330→ title: string
331→ description: string
332→ placeholder: string
333→ value: string
334→ onChange: (value: string) => void
335→ error: string | null
336→ onError: (error: string | null) => void
337→ submitLabel: string
338→ busy: boolean
339→ /** Return true to close the dialog (server errors keep it open). */
340→ onSubmit: (name: string) => Promise<boolean>
341→}) {
342→ const submit = async () => {
343→ const trimmed = value.trim()
344→ if (!trimmed) {
345→ onError('Enter a name first.')
346→ return
347→ }
348→ const ok = await onSubmit(trimmed)
349→ if (ok) onOpenChange(false)
350→ }
351→
352→ return (
353→ <Dialog open={open} onOpenChange={onOpenChange}>
354→ <DialogContent className="sm:max-w-sm">
355→ <DialogHeader>
356→ <DialogTitle>{title}</DialogTitle>
357→ <DialogDescription>{description}</DialogDescription>
358→ </DialogHeader>
359→ <div>
360→ <Input
361→ value={value}
362→ onChange={(e) => onChange(e.target.value)}
363→ placeholder={placeholder}
364→ autoFocus
365→ aria-label={title}
366→ className="h-9 border-zinc-700 bg-zinc-900 font-mono text-xs"
367→ onFocus={(e) => e.currentTarget.select()}
368→ onKeyDown={(e) => {
369→ if (e.key === 'Enter') void submit()
370→ }}
371→ />
372→ {error && <p className="mt-1.5 text-[11px] text-rose-400">{error}</p>}
373→ </div>
374→ <DialogFooter>
375→ <Button variant="outline" className="h-9 border-zinc-700" onClick={() => onOpenChange(false)}>
376→ Cancel
377→ </Button>
378→ <Button
379→ className="h-9 gap-1.5 bg-emerald-400 text-zinc-950 hover:bg-emerald-300"
380→ disabled={busy}
381→ onClick={() => void submit()}
382→ >
383→ {busy ? 'Working…' : submitLabel}
384→ </Button>
385→ </DialogFooter>
386→ </DialogContent>
387→ </Dialog>
388→ )
389→}
390→
391→// ---------------------------------------------------------------------------
392→// the tab
393→// ---------------------------------------------------------------------------
394→
395→type DialogKind = 'createPreset' | 'newFolder' | 'rename' | null
396→
397→interface DeleteTarget {
398→ kind: 'folder' | 'preset'
399→ name: string
400→ /** Folder path (folder delete) or preset id (preset delete). */
401→ path: string
402→ presetCount: number
403→ folderCount: number
404→}
405→
406→export function PresetsTab() {
407→ const { toast } = useToast()
408→ const loadPreset = useLoadPreset()
409→
410→ const presets = useAvaStore((s) => s.presets)
411→ const folders = useAvaStore((s) => s.folders)
412→ const selectedFolder = useAvaStore((s) => s.selectedFolder)
413→ const selectedPresetId = useAvaStore((s) => s.selectedPresetId)
414→ const randomize = useAvaStore((s) => s.randomize)
415→ const randomIntervalSec = useAvaStore((s) => s.randomIntervalSec)
416→ const randomScope = useAvaStore((s) => s.randomScope)
417→
418→ const [dialog, setDialog] = useState<DialogKind>(null)
419→ const [nameValue, setNameValue] = useState('')
420→ const [nameError, setNameError] = useState<string | null>(null)
421→ const [busy, setBusy] = useState(false)
422→ const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null)
423→ const [delayText, setDelayText] = useState(String(randomIntervalSec))
424→ const [delayError, setDelayError] = useState(false)
425→ const importInputRef = useRef<HTMLInputElement>(null)
426→
427→ // freshness refresh whenever the tab mounts (initial seed happens on app load)
428→ useEffect(() => {
429→ void refreshPresets()
430→ }, [])
431→
432→ // keep the delay input in sync when the store value changes elsewhere
433→ useEffect(() => {
434→ setDelayText(String(randomIntervalSec))
435→ }, [randomIntervalSec])
436→
437→ const tree = useMemo(() => buildTree(folders, presets), [folders, presets])
438→ const selectedPreset = useMemo(
439→ () => presets.find((p) => p.id === selectedPresetId) ?? null,
440→ [presets, selectedPresetId],
441→ )
442→ const folderProtected = PROTECTED_FOLDERS.has(selectedFolder)
443→ const folderName = selectedFolder.split('/').pop() ?? selectedFolder
444→ const folderPrefix = `${selectedFolder}/`
445→ const folderPresetCount = presets.filter(
446→ (p) => p.folderPath === selectedFolder || p.folderPath.startsWith(folderPrefix),
447→ ).length
448→ const childFolderCount = folders.filter(
449→ (f) => f.startsWith(folderPrefix) && !f.slice(folderPrefix.length).includes('/'),
450→ ).length
451→ const scopeCount = presets.filter(
452→ (p) => randomScope === 'root' || p.folderPath === randomScope || p.folderPath.startsWith(`${randomScope}/`),
453→ ).length
454→ const scopeLabel = randomScope === 'root' ? 'all presets' : (randomScope.split('/').pop() ?? randomScope)
455→
456→ // -----------------------------------------------------------------
457→ // actions
458→ // -----------------------------------------------------------------
459→
460→ const submitCreatePreset = async (name: string): Promise<boolean> => {
461→ setBusy(true)
462→ try {
463→ const res = await fetch('/api/presets', {
464→ method: 'POST',
465→ headers: { 'Content-Type': 'application/json' },
466→ body: JSON.stringify({ name, content: STARTER_TEMPLATE, folderPath: selectedFolder }),
467→ })
468→ const data = (await res.json().catch(() => null)) as { preset?: PresetItem; error?: string } | null
469→ if (!res.ok || !data?.preset) {
470→ toast({
471→ title: 'Preset not created',
472→ description: data?.error ?? `Server responded ${res.status}.`,
473→ variant: 'destructive',
474→ })
475→ return false
476→ }
477→ await refreshPresets()
478→ const st = useAvaStore.getState()
479→ st.setEditTarget({ id: data.preset.id, name: data.preset.name, folderPath: data.preset.folderPath })
480→ st.setScriptText(STARTER_TEMPLATE)
481→ applyScriptText(STARTER_TEMPLATE)
482→ st.setActivePresetId(data.preset.id)
483→ st.selectPreset(data.preset.id)
484→ st.setTab('script')
485→ toast({
486→ title: 'Preset created',
487→ description: `"${data.preset.name}" created — now edit and Save.`,
488→ })
489→ return true
490→ } catch {
491→ toast({
492→ title: 'Preset not created',
493→ description: 'Network error while creating the preset.',
494→ variant: 'destructive',
495→ })
496→ return false
497→ } finally {
498→ setBusy(false)
499→ }
500→ }
501→
502→ const submitNewFolder = async (name: string): Promise<boolean> => {
503→ const path = `${selectedFolder}/${name}`
504→ setBusy(true)
505→ try {
506→ const res = await fetch('/api/folders', {
507→ method: 'POST',
508→ headers: { 'Content-Type': 'application/json' },
509→ body: JSON.stringify({ path }),
510→ })
511→ const data = (await res.json().catch(() => null)) as { folder?: { path: string }; error?: string } | null
512→ if (!res.ok || !data?.folder) {
513→ toast({
514→ title: 'Folder not created',
515→ description: data?.error ?? `Server responded ${res.status}.`,
516→ variant: 'destructive',
517→ })
518→ return false
519→ }
520→ await refreshPresets()
521→ const st = useAvaStore.getState()
522→ st.toggleFolder(selectedFolder, true)
523→ st.selectFolder(data.folder.path)
524→ toast({ title: 'Folder created', description: `"${name}" added under ${selectedFolder}.` })
525→ return true
526→ } catch {
527→ toast({
528→ title: 'Folder not created',
529→ description: 'Network error while creating the folder.',
530→ variant: 'destructive',
531→ })
532→ return false
533→ } finally {
534→ setBusy(false)
535→ }
536→ }
537→
538→ const submitRename = async (name: string): Promise<boolean> => {
539→ setBusy(true)
540→ try {
541→ if (selectedPreset) {
542→ const res = await fetch(`/api/presets/${selectedPreset.id}`, {
543→ method: 'PUT',
544→ headers: { 'Content-Type': 'application/json' },
545→ body: JSON.stringify({ name }),
546→ })
547→ const data = (await res.json().catch(() => null)) as { preset?: PresetItem; error?: string } | null
548→ if (!res.ok || !data?.preset) {
549→ toast({
550→ title: 'Rename failed',
551→ description: data?.error ?? `Server responded ${res.status}.`,
552→ variant: 'destructive',
553→ })
554→ return false
555→ }
556→ await refreshPresets()
557→ const st = useAvaStore.getState()
558→ if (st.editTarget?.id === selectedPreset.id) {
559→ st.setEditTarget({
560→ id: data.preset.id,
561→ name: data.preset.name,
562→ folderPath: data.preset.folderPath,
563→ })
564→ }
565→ toast({
566→ title: 'Preset renamed',
567→ description: `"${selectedPreset.name}" is now "${data.preset.name}".`,
568→ })
569→ return true
570→ }
571→ // folder rename — everything below moves with it
572→ const res = await fetch('/api/folders', {
573→ method: 'PATCH',
574→ headers: { 'Content-Type': 'application/json' },
575→ body: JSON.stringify({ path: selectedFolder, newName: name }),
576→ })
577→ const data = (await res.json().catch(() => null)) as { to?: string; error?: string } | null
578→ if (!res.ok || !data?.to) {
579→ toast({
580→ title: 'Rename failed',
581→ description: data?.error ?? `Server responded ${res.status}.`,
582→ variant: 'destructive',
583→ })
584→ return false
585→ }
586→ await refreshPresets()
587→ const st = useAvaStore.getState()
588→ st.selectFolder(data.to)
589→ if (
590→ st.editTarget &&
591→ (st.editTarget.folderPath === selectedFolder || st.editTarget.folderPath.startsWith(folderPrefix))
592→ ) {
593→ st.setEditTarget({
594→ ...st.editTarget,
595→ folderPath: data.to + st.editTarget.folderPath.slice(selectedFolder.length),
596→ })
597→ }
598→ toast({ title: 'Folder renamed', description: `Now at ${data.to}.` })
599→ return true
600→ } catch {
601→ toast({
602→ title: 'Rename failed',
603→ description: 'Network error while renaming.',
604→ variant: 'destructive',
605→ })
606→ return false
607→ } finally {
608→ setBusy(false)
609→ }
610→ }
611→
612→ const openDialog = (kind: Exclude<DialogKind, null>, initialName: string) => {
613→ setDialog(kind)
614→ setNameValue(initialName)
615→ setNameError(null)
616→ }
617→
618→ const openPresetDelete = () => {
619→ if (!selectedPreset) return
620→ setDeleteTarget({
621→ kind: 'preset',
622→ name: selectedPreset.name,
623→ path: selectedPreset.id,
624→ presetCount: 1,
625→ folderCount: 0,
626→ })
627→ }
628→
629→ const openFolderDelete = () => {
630→ const presetCount = presets.filter(
631→ (p) => p.folderPath === selectedFolder || p.folderPath.startsWith(folderPrefix),
632→ ).length
633→ const folderCount = folders.filter((f) => f.startsWith(folderPrefix)).length
634→ setDeleteTarget({ kind: 'folder', name: folderName, path: selectedFolder, presetCount, folderCount })
635→ }
636→
637→ const confirmDelete = async () => {
638→ if (!deleteTarget || busy) return
639→ setBusy(true)
640→ try {
641→ const isFolder = deleteTarget.kind === 'folder'
642→ const res = await fetch(
643→ isFolder ? `/api/folders?path=${encodeURIComponent(deleteTarget.path)}` : `/api/presets/${deleteTarget.path}`,
644→ { method: 'DELETE' },
645→ )
646→ const data = (await res.json().catch(() => null)) as { deleted?: boolean; error?: string } | null
647→ if (!res.ok) {
648→ toast({
649→ title: 'Delete failed',
650→ description: data?.error ?? `Server responded ${res.status}.`,
651→ variant: 'destructive',
652→ })
653→ return
654→ }
655→ await refreshPresets()
656→ toast({
657→ title: isFolder ? 'Folder deleted' : 'Preset deleted',
658→ description: isFolder
659→ ? `"${deleteTarget.name}" and everything inside it removed.`
660→ : `"${deleteTarget.name}" removed.`,
661→ })
662→ } catch {
663→ toast({
664→ title: 'Delete failed',
665→ description: 'Network error while deleting.',
666→ variant: 'destructive',
667→ })
668→ } finally {
669→ setBusy(false)
670→ setDeleteTarget(null)
671→ }
672→ }
673→
674→ const importZip = async (file: File) => {
675→ setBusy(true)
676→ try {
677→ const form = new FormData()
678→ form.append('file', file)
679→ form.append('targetPath', selectedFolder)
680→ const res = await fetch('/api/presets/import', { method: 'POST', body: form })
681→ const data = (await res.json().catch(() => null)) as
682→ | { imported?: number; skipped?: number; foldersCreated?: number; error?: string }
683→ | null
684→ if (!res.ok || !data) {
685→ toast({
686→ title: 'Import failed',
687→ description: data?.error ?? `Server responded ${res.status}.`,
688→ variant: 'destructive',
689→ })
690→ return
691→ }
692→ await refreshPresets()
693→ useAvaStore.getState().toggleFolder(selectedFolder, true)
694→ let summary = `Imported ${data.imported ?? 0} presets`
695→ if ((data.skipped ?? 0) > 0) summary += `, ${data.skipped} duplicates skipped`
696→ if ((data.foldersCreated ?? 0) > 0) summary += ` — ${data.foldersCreated} folders created`
697→ toast({ title: 'Import complete', description: `${summary}.` })
698→ } catch {
699→ toast({
700→ title: 'Import failed',
701→ description: 'Network error while importing.',
702→ variant: 'destructive',
703→ })
704→ } finally {
705→ setBusy(false)
706→ }
707→ }
708→
709→ // -----------------------------------------------------------------
710→ // randomizer controls (timer runs in the store)
711→ // -----------------------------------------------------------------
712→
713→ const onDelayChange = (raw: string) => {
714→ setDelayText(raw)
715→ const result = useAvaStore.getState().setRandomIntervalSec(Number(raw))
716→ setDelayError(!result.ok)
717→ if (result.ok && result.value !== undefined) setDelayText(String(result.value))
718→ }
719→
720→ const onDelayBlur = () => {
721→ // invalid / partial input never persists — revert to the committed value
722→ if (delayError) setDelayText(String(randomIntervalSec))
723→ }
724→
725→ // -----------------------------------------------------------------
726→ // render
727→ // -----------------------------------------------------------------
728→
729→ return (
730→ <div className="space-y-3">
731→ <div className="flex items-center justify-between gap-2">
732→ <p className="text-xs text-zinc-500">
733→ <span className="font-mono tabular-nums text-zinc-400">{presets.length}</span> presets ·{' '}
734→ <span className="font-mono tabular-nums text-zinc-400">{folders.length}</span> folders
735→ </p>
736→ <p className="text-[11px] text-zinc-600">double-click a preset to run it</p>
737→ </div>
738→
739→ <div className="flex flex-col gap-3 sm:flex-row">
740→ {/* LEFT — folder/file tree */}
741→ <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">
742→ <div
743→ role="tree"
744→ aria-label="Preset library"
745→ className={cn(thinScrollbar, 'max-h-[420px] overflow-y-auto pr-1 lg:max-h-[480px]')}
746→ >
747→ {tree.map((node) => (
748→ <TreeFolder key={node.path} node={node} />
749→ ))}
750→ {presets.length === 0 && (
751→ <p className="px-2 py-3 text-center text-[11px] italic text-zinc-600">Loading presets…</p>
752→ )}
753→ </div>
754→ </div>
755→
756→ {/* RIGHT — management panel */}
757→ <div className="flex min-w-0 flex-1 flex-col rounded-lg border border-zinc-800/80 bg-zinc-950/40 p-3">
758→ <div className="flex items-start gap-2">
759→ {selectedPreset ? (
760→ <FileText className="mt-0.5 size-4 shrink-0 text-emerald-300/80" aria-hidden />
761→ ) : (
762→ <Folder className="mt-0.5 size-4 shrink-0 text-emerald-300/80" aria-hidden />
763→ )}
764→ <div className="min-w-0 flex-1">
765→ <p
766→ className="truncate text-xs font-medium text-zinc-100"
767→ title={selectedPreset ? selectedPreset.name : selectedFolder}
768→ >
769→ {selectedPreset ? selectedPreset.name : folderName}
770→ </p>
771→ <p
772→ className="truncate font-mono text-[10px] text-zinc-500"
773→ title={selectedPreset ? selectedPreset.folderPath : selectedFolder}
774→ >
775→ {selectedPreset ? selectedPreset.folderPath : selectedFolder}
776→ </p>
777→ {!selectedPreset && (
778→ <p className="mt-0.5 font-mono text-[10px] tabular-nums text-zinc-600">
779→ {plural(folderPresetCount, 'preset')} · {plural(childFolderCount, 'subfolder')}
780→ </p>
781→ )}
782→ </div>
783→ </div>
784→
785→ <div className="mt-3 space-y-2">
786→ {selectedPreset ? (
787→ <>
788→ <Button
789→ size="sm"
790→ onClick={() => loadPreset(selectedPreset)}
791→ className="h-9 w-full gap-1.5 bg-emerald-400 text-zinc-950 hover:bg-emerald-300"
792→ title="Run this preset on the engine and open it in the editor"
793→ >
794→ <Play className="size-3.5" /> Load Preset
795→ </Button>
796→ <div className="grid grid-cols-2 gap-2">
797→ <Button
798→ size="sm"
799→ variant="outline"
800→ onClick={() => openDialog('rename', selectedPreset.name)}
801→ title="Rename this preset (within its folder)"
802→ className="h-9 gap-1.5 border-zinc-700"
803→ >
804→ <Pencil className="size-3.5" /> Rename
805→ </Button>
806→ <Button
807→ size="sm"
808→ variant="outline"
809→ onClick={openPresetDelete}
810→ title="Delete this preset"
811→ className="h-9 gap-1.5 border-zinc-700 hover:border-rose-500/40 hover:text-rose-300"
812→ >
813→ <Trash2 className="size-3.5" /> Delete
814→ </Button>
815→ </div>
816→ </>
817→ ) : (
818→ <>
819→ <Button
820→ size="sm"
821→ variant="outline"
822→ onClick={() => openDialog('createPreset', '')}
823→ title={`Create a new preset from the starter template inside ${selectedFolder}`}
824→ className="h-9 w-full gap-1.5 border-zinc-700"
825→ >
826→ <FilePlus2 className="size-3.5" /> Create Preset
827→ </Button>
828→ <Button
829→ size="sm"
830→ variant="outline"
831→ onClick={() => openDialog('newFolder', '')}
832→ title={`Create a subfolder inside ${selectedFolder}`}
833→ className="h-9 w-full gap-1.5 border-zinc-700"
834→ >
835→ <FolderPlus className="size-3.5" /> New Subfolder
836→ </Button>
837→ <div className="grid grid-cols-2 gap-2">
838→ <Button
839→ size="sm"
840→ variant="outline"
841→ disabled={folderProtected}
842→ onClick={() => openDialog('rename', folderName)}
843→ title={folderProtected ? 'Protected folder' : `Rename ${folderName} — everything inside moves with it`}
844→ className="h-9 gap-1.5 border-zinc-700"
845→ >
846→ <Pencil className="size-3.5" /> Rename
847→ </Button>
848→ <Button
849→ size="sm"
850→ variant="outline"
851→ disabled={folderProtected}
852→ onClick={openFolderDelete}
853→ title={folderProtected ? 'Protected folder' : `Delete ${folderName} and everything inside it`}
854→ className="h-9 gap-1.5 border-zinc-700 hover:border-rose-500/40 hover:text-rose-300"
855→ >
856→ <Trash2 className="size-3.5" /> Delete
857→ </Button>
858→ </div>
859→ <div className="grid grid-cols-2 gap-2">
860→ <Button
861→ size="sm"
862→ variant="outline"
863→ asChild
864→ title="Download this folder (with subfolders) as a .zip preserving the tree"
865→ className="h-9 gap-1.5 border-zinc-700"
866→ >
867→ <a href={`/api/presets/export?path=${encodeURIComponent(selectedFolder)}`} download>
868→ <Download className="size-3.5" /> Export .zip
869→ </a>
870→ </Button>
871→ <Button
872→ size="sm"
873→ variant="outline"
874→ disabled={busy}
875→ onClick={() => importInputRef.current?.click()}
876→ title={`Import a .zip of .ava presets into ${selectedFolder} — the tree inside the zip is preserved`}
877→ className="h-9 gap-1.5 border-zinc-700"
878→ >
879→ <Upload className="size-3.5" /> Import .zip
880→ </Button>
881→ </div>
882→ </>
883→ )}
884→ </div>
885→
886→ {/* randomize (club mode) — timer runs in the store */}
887→ <div className="mt-4 border-t border-zinc-800/80 pt-3">
888→ <div className="flex items-center gap-2">
889→ <Checkbox
890→ id="ava-randomize"
891→ checked={randomize}
892→ onCheckedChange={(v) => useAvaStore.getState().setRandomize(v === true)}
893→ aria-label="Randomize presets"
894→ className="size-4 data-[state=checked]:border-emerald-400 data-[state=checked]:bg-emerald-400 data-[state=checked]:text-zinc-950"
895→ />
896→ <Label htmlFor="ava-randomize" className="cursor-pointer text-xs text-zinc-300">
897→ Randomize
898→ </Label>
899→ <div className="ml-auto flex items-center gap-1.5">
900→ <Input
901→ value={delayText}
902→ onChange={(e) => onDelayChange(e.target.value)}
903→ onBlur={onDelayBlur}
904→ inputMode="numeric"
905→ aria-label="Random switch delay in seconds"
906→ className="h-8 w-16 border-zinc-700 bg-zinc-900 px-2 text-center font-mono text-xs tabular-nums"
907→ />
908→ <span className="text-[11px] text-zinc-500">sec</span>
909→ </div>
910→ </div>
911→ {delayError && (
912→ <p className="mt-1.5 text-[11px] text-rose-400">Delay must be between 3 and 60 seconds.</p>
913→ )}
914→ <p className="mt-2 text-[11px] leading-relaxed text-zinc-500">
915→ Random from: <span className="font-mono text-zinc-400">{scopeLabel}</span>
916→ {scopeCount === 0 && <span className="ml-1 text-amber-300/80">— no presets in this folder yet</span>}
917→ </p>
918→ {randomize && (
919→ <div className="mt-2">
920→ <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">
921→ <span className="size-1.5 animate-pulse rounded-full bg-emerald-400" aria-hidden />
922→ ON · every {randomIntervalSec}s
923→ </span>
924→ </div>
925→ )}
926→ </div>
927→ </div>
928→ </div>
929→
930→ {/* name dialogs */}
931→ <NameDialog
932→ open={dialog === 'createPreset'}
933→ onOpenChange={(o) => setDialog(o ? 'createPreset' : null)}
934→ title="Create preset"
935→ description={`New preset from the starter template inside ${selectedFolder}.`}
936→ placeholder="Preset name"
937→ value={nameValue}
938→ onChange={setNameValue}
939→ error={nameError}
940→ onError={setNameError}
941→ submitLabel="Create"
942→ busy={busy}
943→ onSubmit={submitCreatePreset}
944→ />
945→ <NameDialog
946→ open={dialog === 'newFolder'}
947→ onOpenChange={(o) => setDialog(o ? 'newFolder' : null)}
948→ title="New subfolder"
949→ description={`Created inside ${selectedFolder}.`}
950→ placeholder="Folder name"
951→ value={nameValue}
952→ onChange={setNameValue}
953→ error={nameError}
954→ onError={setNameError}
955→ submitLabel="Create"
956→ busy={busy}
957→ onSubmit={submitNewFolder}
958→ />
959→ <NameDialog
960→ open={dialog === 'rename'}
961→ onOpenChange={(o) => setDialog(o ? 'rename' : null)}
962→ title={selectedPreset ? 'Rename preset' : 'Rename folder'}
963→ description={
964→ selectedPreset
965→ ? `Currently "${selectedPreset.name}" in ${selectedPreset.folderPath}.`
966→ : `Currently "${folderName}" — everything inside moves with it.`
967→ }
968→ placeholder="New name"
969→ value={nameValue}
970→ onChange={setNameValue}
971→ error={nameError}
972→ onError={setNameError}
973→ submitLabel="Rename"
974→ busy={busy}
975→ onSubmit={submitRename}
976→ />
977→
978→ {/* delete confirmation */}
979→ <AlertDialog open={deleteTarget !== null} onOpenChange={(o) => !o && setDeleteTarget(null)}>
980→ <AlertDialogContent className="sm:max-w-sm">
981→ <AlertDialogHeader>
982→ <AlertDialogTitle>
983→ {deleteTarget?.kind === 'folder'
984→ ? `Delete "${deleteTarget.name}"?`
985→ : `Delete preset "${deleteTarget?.name ?? ''}"?`}
986→ </AlertDialogTitle>
987→ <AlertDialogDescription>
988→ {deleteTarget?.kind === 'folder'
989→ ? deleteTarget.presetCount > 0 || deleteTarget.folderCount > 0
990→ ? `Delete "${deleteTarget.name}" and everything inside it? This removes ${plural(deleteTarget.presetCount, 'preset')}${deleteTarget.folderCount > 0 ? ` in ${plural(deleteTarget.folderCount, 'folder')}` : ''}. This cannot be undone.`
991→ : `Delete "${deleteTarget.name}"? The folder is empty. This cannot be undone.`
992→ : `Delete preset "${deleteTarget?.name ?? ''}"? This cannot be undone.`}
993→ </AlertDialogDescription>
994→ </AlertDialogHeader>
995→ <AlertDialogFooter>
996→ <AlertDialogCancel className="h-9 border-zinc-700">Cancel</AlertDialogCancel>
997→ <AlertDialogAction
998→ disabled={busy}
999→ onClick={(e) => {
1000→ e.preventDefault()
1001→ void confirmDelete()
1002→ }}
1003→ className="h-9 bg-rose-500 text-zinc-50 hover:bg-rose-400"
1004→ >
1005→ {busy ? 'Deleting…' : 'Delete'}
1006→ </AlertDialogAction>
1007→ </AlertDialogFooter>
1008→ </AlertDialogContent>
1009→ </AlertDialog>
1010→
1011→ {/* hidden zip import picker */}
1012→ <input
1013→ ref={importInputRef}
1014→ type="file"
1015→ accept=".zip,application/zip"
1016→ className="hidden"
1017→ tabIndex={-1}
1018→ aria-hidden
1019→ onChange={(e) => {
1020→ const file = e.target.files?.[0]
1021→ if (file) void importZip(file)
1022→ e.target.value = ''
1023→ }}
1024→ />
1025→ </div>
1026→ )
1027→}
1028→