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