openvisengine/src/components/ava/presets-tab.tsx
2026-10-22 03:29:51 +00:00

245 lines
8.5 KiB
TypeScript
Executable file

// AVA — Presets tab: load/delete preset scripts, download the original .avs
// binaries for the built-ins, save the current editor script as a preset.
'use client'
import { useEffect, useState } from 'react'
import { Download, FilePlus2, Play, Save, Trash2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { originalFileUrl, STARTER_TEMPLATE } from '@/lib/ava/presets'
import { validateScript } from '@/lib/ava/script'
import { refreshPresets, useAvaStore, type PresetItem } from '@/lib/store'
import { applyScriptText } from '@/components/ava/audio-source'
import { modeOfScript, thinScrollbar } from '@/components/ava/shared'
import { useToast } from '@/hooks/use-toast'
import { cn } from '@/lib/utils'
function PresetCard({ preset }: { preset: PresetItem }) {
const { toast } = useToast()
const setTab = useAvaStore((s) => s.setTab)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const [deleting, setDeleting] = useState(false)
useEffect(() => {
if (!confirmingDelete) return
const t = setTimeout(() => setConfirmingDelete(false), 3000)
return () => clearTimeout(t)
}, [confirmingDelete])
const load = () => {
const result = applyScriptText(preset.content)
if (result.ok) {
toast({ title: 'Preset loaded', description: `"${preset.name}" is now running.` })
} else if (result.error) {
toast({
title: 'Preset failed to compile',
description: `[${result.error.section}] line ${result.error.line}: ${result.error.message}`,
variant: 'destructive',
})
setTab('script')
}
}
const remove = async () => {
setDeleting(true)
try {
const res = await fetch(`/api/presets/${preset.id}`, { method: 'DELETE' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
await refreshPresets()
toast({ title: 'Preset deleted', description: `"${preset.name}" removed.` })
} catch {
toast({ title: 'Delete failed', description: 'Could not remove that preset.', variant: 'destructive' })
} finally {
setDeleting(false)
setConfirmingDelete(false)
}
}
const original = preset.isBuiltIn ? originalFileUrl(preset.name) : undefined
return (
<div className="rounded-lg border border-zinc-800/80 bg-zinc-950/40 p-3.5">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h4 className="truncate text-sm font-medium text-zinc-100">{preset.name}</h4>
<p className="truncate text-[11px] text-zinc-500">by {preset.author || 'unknown'}</p>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<Badge variant="outline" className="border-zinc-700 font-mono text-[10px] text-zinc-400">
{modeOfScript(preset.content)}
</Badge>
<Badge
className={cn(
'border-transparent font-mono text-[10px]',
preset.isBuiltIn ? 'bg-emerald-400/15 text-emerald-300' : 'bg-amber-300/15 text-amber-200',
)}
variant="outline"
>
{preset.isBuiltIn ? 'built-in' : 'custom'}
</Badge>
</div>
</div>
<p className="mt-2 line-clamp-3 text-xs leading-relaxed text-zinc-400">{preset.description}</p>
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button size="sm" onClick={load} className="h-9 gap-1.5 bg-emerald-400 text-zinc-950 hover:bg-emerald-300">
<Play className="size-3.5" /> Load
</Button>
{original && (
<Button size="sm" variant="outline" className="h-9 gap-1.5 border-zinc-700" asChild>
<a href={original} download>
<Download className="size-3.5" /> .avs original
</a>
</Button>
)}
{!preset.isBuiltIn && (
<Button
size="sm"
variant={confirmingDelete ? 'destructive' : 'outline'}
className="ml-auto h-9 gap-1.5 border-zinc-700"
disabled={deleting}
onClick={() => (confirmingDelete ? void remove() : setConfirmingDelete(true))}
>
<Trash2 className="size-3.5" />
{confirmingDelete ? 'Confirm?' : 'Delete'}
</Button>
)}
</div>
</div>
)
}
function SavePresetSection() {
const { toast } = useToast()
const scriptText = useAvaStore((s) => s.scriptText)
const [name, setName] = useState('')
const [saving, setSaving] = useState(false)
const save = async () => {
const trimmed = name.trim()
if (!trimmed) {
toast({ title: 'Name required', description: 'Give the preset a name before saving.', variant: 'destructive' })
return
}
const v = validateScript(scriptText)
setSaving(true)
try {
const res = await fetch('/api/presets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: trimmed,
content: scriptText,
author: v.meta?.author || 'you',
description: v.meta?.description || '',
}),
})
const data = (await res.json().catch(() => null)) as { error?: string; updated?: boolean } | null
if (!res.ok) {
toast({
title: 'Save failed',
description: data?.error ?? `Server responded ${res.status}.`,
variant: 'destructive',
})
return
}
await refreshPresets()
toast({
title: data?.updated ? 'Preset updated' : 'Preset saved',
description: `"${trimmed}" is available in the list above.`,
})
setName('')
} catch {
toast({ title: 'Save failed', description: 'Network error while saving the preset.', variant: 'destructive' })
} finally {
setSaving(false)
}
}
return (
<div className="rounded-lg border border-zinc-800/80 bg-zinc-950/40 p-3.5">
<h4 className="text-sm font-medium text-zinc-200">Save current editor script as preset</h4>
<p className="mt-0.5 text-[11px] text-zinc-500">
Saved under the script&apos;s editor content — reuse it any time from this list.
</p>
<div className="mt-3 flex gap-2">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Preset name"
className="h-9 border-zinc-700 bg-zinc-900 font-mono text-xs"
onKeyDown={(e) => {
if (e.key === 'Enter') void save()
}}
/>
<Button
size="sm"
onClick={() => void save()}
disabled={saving}
className="h-9 gap-1.5 bg-emerald-400 text-zinc-950 hover:bg-emerald-300"
>
<Save className="size-3.5" /> Save
</Button>
</div>
</div>
)
}
export function PresetsTab() {
const presets = useAvaStore((s) => s.presets)
const setScriptText = useAvaStore((s) => s.setScriptText)
const setTab = useAvaStore((s) => s.setTab)
// freshness refresh whenever the tab mounts (initial seed happens on app load)
useEffect(() => {
void refreshPresets()
}, [])
const builtIns = presets.filter((p) => p.isBuiltIn)
const customs = presets.filter((p) => !p.isBuiltIn)
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-zinc-500">
{presets.length} preset{presets.length === 1 ? '' : 's'} — classics adapted from the original Winamp AVS
files.
</p>
<Button
size="sm"
variant="outline"
className="h-9 gap-1.5 border-zinc-700"
onClick={() => {
setScriptText(STARTER_TEMPLATE)
setTab('script')
}}
>
<FilePlus2 className="size-3.5" /> New Script
</Button>
</div>
{/* long-list handling: capped height + styled scrollbar */}
<div className={cn('space-y-2.5 pr-1', thinScrollbar, 'max-h-96 overflow-y-auto')}>
{builtIns.map((p) => (
<PresetCard key={p.id} preset={p} />
))}
{customs.length > 0 && (
<div className="pt-1 text-[10px] font-semibold uppercase tracking-widest text-zinc-600">Custom</div>
)}
{customs.map((p) => (
<PresetCard key={p.id} preset={p} />
))}
{presets.length === 0 && (
<div className="rounded-lg border border-dashed border-zinc-800 p-6 text-center text-xs text-zinc-600">
Loading presets…
</div>
)}
</div>
<SavePresetSection />
</div>
)
}