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