     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→