245 lines
8.5 KiB
TypeScript
Executable file
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'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>
|
|
)
|
|
}
|