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