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