1028 lines
No EOL
55 KiB
Text
Executable file
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→ |