openvisengine/tool-results/read_1793082298965_cc5347613a20.txt

1028 lines
No EOL
55 KiB
Text
Executable file

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