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