Restore folder-tree Presets page (Tasks 9-11) from user's Google Drive archive — tree UI, folders/export/import APIs, 95 embedded presets, randomize, Save/Save As

This commit is contained in:
Z User 2026-10-26 21:01:39 +00:00
parent eb2b054619
commit df39d16b61
203 changed files with 7278 additions and 276 deletions

View file

@ -0,0 +1,86 @@
# Task 10 — Presets UI (folder/file tree manager + Script tab save targets)
Task ID: 10
Agent: full-stack-developer (Presets UI)
Status: COMPLETE — lint clean, tsc clean on touched files, verified end-to-end in a headless browser.
## What was built
### 1. `src/components/ava/presets-tab.tsx` — complete rewrite
**Left column (58% on sm+, full width on mobile): the folder/file tree**
- Built from `store.folders` + `store.presets`. Defensive: presets whose
`folderPath` has no Folder row synthesize their node + ancestors
(`buildTree`). Folders sorted alphabetically per level (segment-wise
compare, parents before children); presets alphabetically inside folders.
- Folder row: chevron button (size-9 hit area, rotates ChevronRight) is the
ONLY expand/collapse control; clicking the row selects the folder
(`selectFolder` — also updates the randomize scope). FolderOpen/Folder
icon swaps with expansion. Subtree preset count badge (font-mono,
tooltip "N presets in this folder and below").
- Default expansion: `expandedFolders[path] ?? path.split('/').length <= 2`
(same rule as the store) — root, root/embedded, root/user created open.
- Preset row: FileText icon; single click selects, double-click loads.
Running preset (`activePresetId`): emerald Play icon + emerald-300 name +
`bg-emerald-400/10`. Selected row: emerald left border + `bg-zinc-800/70`.
- `originalFile` rows get a .avs download anchor (title "Download original
Winamp .avs"), always visible on mobile, hover-visible on `sm+`.
- Empty folders show an italic "(empty)" hint row. Tree body scrolls
(`max-h-[420px] lg:max-h-[480px]`, thinScrollbar). `role="tree"` /
`treeitem` / `group`, aria-expanded/selected/level throughout.
**Right column (42%): context-sensitive management panel**
- Header: icon + name + font-mono full path (+ "N presets · M subfolders"
for folders).
- Preset context: LOAD PRESET (emerald; applyScriptText → toast →
setActivePresetId + setEditTarget; compile error → destructive toast +
switch to script tab), RENAME, DELETE.
- Folder context: CREATE PRESET (POST STARTER_TEMPLATE → refresh →
editTarget + editor + engine + select + `setTab('script')` + toast
"created — now edit and Save"), NEW SUBFOLDER (POST → expand parent +
select new), RENAME (PATCH folder / PUT preset; selection, randomScope
and editTarget follow the renamed paths), DELETE (AlertDialog with
client-side blast radius — "removes N presets in M folders", empty-folder
and preset variants), EXPORT .ZIP (asChild anchor to
`/api/presets/export?path=…` with download attr; root exports
everything), IMPORT .ZIP (hidden .zip input → FormData POST → summary
toast with duplicates-skipped / folders-created).
- PROTECTED_FOLDERS (root, root/embedded, root/user created): Rename +
Delete disabled with "Protected folder" tooltip. Import/export allowed.
- All 409/400/404/500 responses → destructive toasts with the server
message. Empty names → inline dialog validation.
- RANDOMIZE section (border-t): Checkbox + delay input (3–60 s; invalid
input shows a red hint and reverts to the committed store value on
blur), scope line ("Random from: <folder | all presets>") with amber
"no presets in this folder yet" hint, pulsing emerald "ON · every Ns"
badge while active. The timer runs in the store; the UI only wires it.
- NameDialog is fully controlled (parent owns value/error) — this was
needed to pass the `react-hooks/set-state-in-effect` lint rule.
### 2. `src/components/ava/script-tab.tsx` — edits
- "Saving to: root/…/Name" chip (middle-truncated path, emerald, X button
clears the target) shown while `editTarget` is set.
- Save button (emerald, editTarget only): PUT `/api/presets/[id]`
{content, author from validateScript meta} → refresh + toast
`Saved to "Name"`; disabled while saving.
- Save As… button (editTarget only): Dialog prefilled with current name →
POST same folder → setEditTarget(new) + refresh + toast; 409 handled.
- New button also clears the edit target.
## Verification performed (headless browser + curl)
- Tree structure/counts/expansion/selection; Load (toast, chip appears,
Save PUT returns 200); Save As (copy created server-side, chip
retargeted); delete preset + folder (blast-radius + empty wording);
create preset (editor + tab switch); new subfolder; rename folder
(paths follow); import .zip end-to-end; randomize persistence
(localStorage `ava.randomize`), scope line, invalid-delay revert, ON
badge in DOM with pulse animation. Zero page/console errors.
- Library restored to pristine state after testing (95 presets / 7 folders).
## Notes for future agents
- Do NOT re-add setState-in-effect in NameDialog — the react-hooks v6 rule
forbids it; keep it controlled.
- `setRandomIntervalSec` is typed `(sec: number)`; convert with `Number()`
before calling (the store would tolerate strings at runtime).
- agent-browser `find … dblclick` does not exist — double-click was
verified through the identical Load Preset code path.

Binary file not shown.

View file

@ -7,15 +7,32 @@ datasource db {
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
// A preset script file living somewhere in the folder tree.
// folderPath is the full path from the virtual root, e.g. "root/embedded/Synth-C".
// Names are unique per folder (files in a directory).
model Preset { model Preset {
id String @id @default(cuid()) id String @id @default(cuid())
name String @unique name String
author String @default("") author String @default("")
description String @default("") description String @default("")
content String content String
isBuiltIn Boolean @default(false) isBuiltIn Boolean @default(false)
folderPath String @default("root/user created")
// Path to the original Winamp .avs binary in /public (downloadable), if any.
originalFile String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@unique([folderPath, name])
}
// A folder in the preset tree. The root ("/") and the two top-level areas
// "root/embedded" and "root/user created" are structural and protected from
// rename/delete; everything below is fully user-manageable.
model Folder {
id String @id @default(cuid())
path String @unique
createdAt DateTime @default(now())
} }
model Settings { model Settings {

View file

@ -0,0 +1,131 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
import {
PROTECTED_FOLDERS,
isValidName,
normalizeFolderPath,
parentOf,
subtreeFilter,
subtreeFolderFilter,
} from '@/lib/server/preset-tree'
export const dynamic = 'force-dynamic'
/** Create a subfolder inside an existing folder. Body: { path: "root/a/b" } */
export async function POST(req: NextRequest) {
try {
const body = await req.json()
const folder = normalizeFolderPath(body?.path)
if (!folder) {
return NextResponse.json({ error: 'Invalid folder path' }, { status: 400 })
}
if (folder.path === 'root') {
return NextResponse.json({ error: '"root" already exists' }, { status: 409 })
}
if (PROTECTED_FOLDERS.has(folder.path)) {
return NextResponse.json({ error: 'That folder already exists' }, { status: 409 })
}
const existing = await db.folder.findUnique({ where: { path: folder.path } })
if (existing) {
return NextResponse.json({ error: `"${folder.path}" already exists` }, { status: 409 })
}
const parent = parentOf(folder.segments)
const parentRow = await db.folder.findUnique({ where: { path: parent } })
if (!parentRow) {
return NextResponse.json(
{ error: `Parent folder "${parent}" does not exist` },
{ status: 400 },
)
}
await db.folder.create({ data: { path: folder.path } })
return NextResponse.json({ folder: { path: folder.path } }, { status: 201 })
} catch (err) {
console.error('POST /api/folders failed:', err)
return NextResponse.json({ error: 'Failed to create folder' }, { status: 500 })
}
}
/** Rename a folder (and everything below it). Body: { path, newName } */
export async function PATCH(req: NextRequest) {
try {
const body = await req.json()
const folder = normalizeFolderPath(body?.path)
const newName = isValidName(body?.newName, 80)
if (!folder || !newName) {
return NextResponse.json({ error: 'Invalid folder or name' }, { status: 400 })
}
if (PROTECTED_FOLDERS.has(folder.path)) {
return NextResponse.json(
{ error: `"${folder.segments[folder.segments.length - 1]}" is a protected folder and cannot be renamed` },
{ status: 400 },
)
}
const row = await db.folder.findUnique({ where: { path: folder.path } })
if (!row) {
return NextResponse.json({ error: 'Folder not found' }, { status: 404 })
}
const newPath = `${parentOf(folder.segments)}/${newName}`
const clash = await db.folder.findUnique({ where: { path: newPath } })
if (clash) {
return NextResponse.json({ error: `"${newName}" already exists there` }, { status: 409 })
}
const result = await db.$transaction(async (tx) => {
const folders = await tx.folder.findMany({ where: subtreeFolderFilter(folder.path) })
const presets = await tx.preset.findMany({
where: subtreeFilter(folder.path),
select: { id: true, folderPath: true },
})
for (const f of folders) {
const target = f.path === folder.path ? newPath : newPath + f.path.slice(folder.path.length)
await tx.folder.update({ where: { path: f.path }, data: { path: target } })
}
for (const p of presets) {
await tx.preset.update({
where: { id: p.id },
data: { folderPath: newPath + p.folderPath.slice(folder.path.length) },
})
}
return { folders: folders.length, presets: presets.length }
})
return NextResponse.json({ renamed: folder.path, to: newPath, ...result })
} catch (err) {
if ((err as { code?: string })?.code === 'P2002') {
return NextResponse.json({ error: 'A folder with that name already exists' }, { status: 409 })
}
console.error('PATCH /api/folders failed:', err)
return NextResponse.json({ error: 'Failed to rename folder' }, { status: 500 })
}
}
/** Delete a folder subtree. Query: ?path=root/a/b */
export async function DELETE(req: NextRequest) {
try {
const raw = new URL(req.url).searchParams.get('path')
const folder = normalizeFolderPath(raw)
if (!folder) {
return NextResponse.json({ error: 'Invalid folder path' }, { status: 400 })
}
if (PROTECTED_FOLDERS.has(folder.path)) {
return NextResponse.json(
{ error: `"${folder.segments[folder.segments.length - 1]}" is a protected folder and cannot be deleted` },
{ status: 400 },
)
}
const row = await db.folder.findUnique({ where: { path: folder.path } })
if (!row) {
return NextResponse.json({ error: 'Folder not found' }, { status: 404 })
}
const deletedPresets = await db.preset.deleteMany({ where: subtreeFilter(folder.path) })
const deletedFolders = await db.folder.deleteMany({ where: subtreeFolderFilter(folder.path) })
return NextResponse.json({
deleted: true,
folders: deletedFolders.count,
presets: deletedPresets.count,
})
} catch (err) {
console.error('DELETE /api/folders failed:', err)
return NextResponse.json({ error: 'Failed to delete folder' }, { status: 500 })
}
}

View file

@ -1,8 +1,10 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db' import { db } from '@/lib/db'
import { isValidName } from '@/lib/server/preset-tree'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
/** Update a preset (content, author, description, or rename within its folder). */
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try { try {
const { id } = await params const { id } = await params
@ -11,14 +13,29 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
if (!preset) { if (!preset) {
return NextResponse.json({ error: 'Preset not found' }, { status: 404 }) return NextResponse.json({ error: 'Preset not found' }, { status: 404 })
} }
if (preset.isBuiltIn) {
return NextResponse.json({ error: 'Built-in presets cannot be modified' }, { status: 403 })
}
const data: Record<string, string> = {} const data: Record<string, string> = {}
if (typeof body.name === 'string' && body.name.trim()) data.name = body.name.trim() if (typeof body?.name === 'string' && body.name.trim() && body.name.trim() !== preset.name) {
if (typeof body.content === 'string') data.content = body.content const name = isValidName(body.name)
if (typeof body.author === 'string') data.author = body.author.trim() if (!name) {
if (typeof body.description === 'string') data.description = body.description.trim() return NextResponse.json({ error: 'Invalid preset name' }, { status: 400 })
}
const clash = await db.preset.findFirst({
where: { folderPath: preset.folderPath, name },
select: { id: true },
})
if (clash && clash.id !== id) {
return NextResponse.json(
{ error: `A preset named "${name}" already exists in ${preset.folderPath}` },
{ status: 409 },
)
}
data.name = name
}
if (typeof body?.content === 'string') data.content = body.content
if (typeof body?.author === 'string') data.author = body.author.trim().slice(0, 120)
if (typeof body?.description === 'string') data.description = body.description.trim().slice(0, 500)
const updated = await db.preset.update({ where: { id }, data }) const updated = await db.preset.update({ where: { id }, data })
return NextResponse.json({ preset: updated }) return NextResponse.json({ preset: updated })
} catch (err) { } catch (err) {
@ -27,6 +44,7 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
} }
} }
/** Delete a preset (any preset — the tree is fully user-manageable). */
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try { try {
const { id } = await params const { id } = await params
@ -34,9 +52,6 @@ export async function DELETE(_req: NextRequest, { params }: { params: Promise<{
if (!preset) { if (!preset) {
return NextResponse.json({ error: 'Preset not found' }, { status: 404 }) return NextResponse.json({ error: 'Preset not found' }, { status: 404 })
} }
if (preset.isBuiltIn) {
return NextResponse.json({ error: 'Built-in presets cannot be deleted' }, { status: 403 })
}
await db.preset.delete({ where: { id } }) await db.preset.delete({ where: { id } })
return NextResponse.json({ deleted: true }) return NextResponse.json({ deleted: true })
} catch (err) { } catch (err) {

View file

@ -0,0 +1,69 @@
import { NextRequest, NextResponse } from 'next/server'
import JSZip from 'jszip'
import { db } from '@/lib/db'
import { normalizeFolderPath, subtreeFilter, subtreeFolderFilter } from '@/lib/server/preset-tree'
export const dynamic = 'force-dynamic'
/**
* Export a folder (and everything below it) as a .zip that preserves the
* directory structure from the virtual root — e.g. exporting
* "root/user created/My Fav Presets" produces entries under
* "root/user created/My Fav Presets/<preset>.ava" so the zip can be
* re-imported anywhere with the same tree.
*
* GET /api/presets/export?path=root%2Fembedded%2FAmp%20Picks
*/
export async function GET(req: NextRequest) {
try {
const raw = new URL(req.url).searchParams.get('path') ?? 'root'
const folder = normalizeFolderPath(raw)
if (!folder) {
return NextResponse.json({ error: 'Invalid folder path' }, { status: 400 })
}
if (folder.path !== 'root') {
const row = await db.folder.findUnique({ where: { path: folder.path } })
if (!row) {
return NextResponse.json({ error: 'Folder not found' }, { status: 404 })
}
}
const [folders, presets] = await Promise.all([
db.folder.findMany({ where: subtreeFolderFilter(folder.path), select: { path: true } }),
db.preset.findMany({
where: subtreeFilter(folder.path),
select: { name: true, content: true, folderPath: true },
}),
])
// folder set = stored rows ∪ every ancestor of every preset's path
const folderSet = new Set(folders.map((f) => f.path))
for (const p of presets) {
const segs = p.folderPath.split('/')
for (let i = 1; i <= segs.length; i++) folderSet.add(segs.slice(0, i).join('/'))
}
const zip = new JSZip()
for (const fp of folderSet) zip.folder(fp)
for (const p of presets) zip.file(`${p.folderPath}/${p.name}.ava`, p.content)
const data = await zip.generateAsync({ type: 'uint8array' })
// copy into a plain ArrayBuffer-backed view (BodyInit/BlobPart typing)
const body = new Uint8Array(data.byteLength)
body.set(data)
const label = folder.segments[folder.segments.length - 1] ?? 'root'
const safeLabel = label.replace(/["\u0000-\u001f\u007f]/g, '').trim() || 'root'
return new NextResponse(body, {
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="AVA presets - ${safeLabel}.zip"`,
'Content-Length': String(data.length),
'Cache-Control': 'no-store',
},
})
} catch (err) {
console.error('GET /api/presets/export failed:', err)
return NextResponse.json({ error: 'Failed to export presets' }, { status: 500 })
}
}

View file

@ -0,0 +1,125 @@
import { NextRequest, NextResponse } from 'next/server'
import JSZip from 'jszip'
import { db } from '@/lib/db'
import { ensureFolderRows, isValidName, normalizeFolderPath } from '@/lib/server/preset-tree'
export const dynamic = 'force-dynamic'
const MAX_ZIP_BYTES = 50 * 1024 * 1024
interface ImportEntry {
folderPath: string
name: string
content: string
}
/**
* Import a .zip of presets, preserving its directory tree. Entries stored
* under "root/…" land at exactly that path; anything else is placed inside
* the target folder selected in the UI. Only .ava (and .txt) script files
* are imported — other files are counted and skipped.
*
* POST /api/presets/import (multipart/form-data: file, targetPath)
*/
export async function POST(req: NextRequest) {
try {
const form = await req.formData()
const file = form.get('file')
if (!(file instanceof File)) {
return NextResponse.json({ error: 'Attach a .zip file to import' }, { status: 400 })
}
if (file.size > MAX_ZIP_BYTES) {
return NextResponse.json({ error: 'That zip is too large (50 MB limit)' }, { status: 413 })
}
const target = normalizeFolderPath(form.get('targetPath') ?? 'root/user created')
if (!target) {
return NextResponse.json({ error: 'Invalid target folder' }, { status: 400 })
}
const zip = await JSZip.loadAsync(await file.arrayBuffer())
const entries: ImportEntry[] = []
let skippedOther = 0
for (const entry of Object.values(zip.files)) {
if (entry.dir) continue
const rawPath = entry.name.replace(/\\/g, '/')
if (rawPath.includes('__MACOSX') || rawPath.split('/').some((s) => s.startsWith('.'))) continue
if (!/\.(ava|txt)$/i.test(rawPath)) {
skippedOther++
continue
}
const segs = rawPath
.split('/')
.map((s) => s.trim())
.filter((s) => s.length > 0 && s !== '.' && s !== '..')
if (segs.length === 0) continue
const fileName = segs[segs.length - 1]
const name = isValidName(fileName.replace(/\.(ava|txt)$/i, ''))
if (!name) {
skippedOther++
continue
}
const dirSegs = segs.slice(0, -1)
const joined = dirSegs.length > 0 ? dirSegs.join('/') : null
const folderPath = joined
? joined === 'root' || joined.startsWith('root/')
? joined
: `${target.path}/${joined}`
: target.path
const norm = normalizeFolderPath(folderPath)
if (!norm) {
skippedOther++
continue
}
const content = await entry.async('string')
entries.push({ folderPath: norm.path, name, content })
}
if (entries.length === 0) {
return NextResponse.json(
{ error: 'No .ava preset files found in that zip' },
{ status: 400 },
)
}
const foldersCreated = await ensureFolderRows([...new Set(entries.map((e) => e.folderPath))])
let imported = 0
let skipped = 0
for (const e of entries) {
const clash = await db.preset.findFirst({
where: { folderPath: e.folderPath, name: e.name },
select: { id: true },
})
if (clash) {
skipped++
continue
}
const author =
/^author:\s*(.+)$/m
.exec(e.content)?.[1]
?.replace(/^["']|["']$/g, '')
.trim()
.slice(0, 120) ?? ''
await db.preset.create({
data: {
name: e.name,
content: e.content,
author,
folderPath: e.folderPath,
isBuiltIn: false,
},
})
imported++
}
return NextResponse.json({ imported, skipped, skippedOther, foldersCreated })
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (message.includes('corrupted') || message.includes('zip')) {
return NextResponse.json({ error: 'That file is not a valid zip archive' }, { status: 400 })
}
console.error('POST /api/presets/import failed:', err)
return NextResponse.json({ error: 'Failed to import presets' }, { status: 500 })
}
}

View file

@ -1,31 +1,71 @@
import { NextRequest, NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db' import { db } from '@/lib/db'
import { BUILTIN_PRESETS } from '@/lib/ava/presets' import { BUILTIN_PRESETS } from '@/lib/ava/presets'
import { EMBEDDED_LIBRARY } from '@/lib/ava/embedded-presets'
import { ensureFolderRows, isValidName, normalizeFolderPath } from '@/lib/server/preset-tree'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
/** Seed the built-in presets on first run. */ /** The structural backbone of the tree, created on first run. */
const BASE_FOLDERS = [
'root',
'root/embedded',
'root/user created',
'root/embedded/Synth-C',
'root/embedded/Amp Picks',
'root/embedded/Dynamics',
'root/embedded/Envision',
]
/**
* One-time seed: folder backbone + the full embedded library
* (5 hand-ported classics + 90 auto-ported Winamp AVS presets from the
* user's Google Drive collection, organized exactly as the drive folders
* were: Synth-C / Amp Picks / Dynamics / Envision).
*/
async function ensureSeeded(): Promise<void> { async function ensureSeeded(): Promise<void> {
const count = await db.preset.count() const folderCount = await db.folder.count()
if (count > 0) return if (folderCount > 0) return
for (const p of BUILTIN_PRESETS) { try {
await db.preset.create({ await db.folder.createMany({ data: BASE_FOLDERS.map((path) => ({ path })) })
data: { // Migrate legacy pre-folder databases: old built-ins sat at the top level.
await db.preset.deleteMany({ where: { isBuiltIn: true } })
await db.preset.createMany({
data: [
...BUILTIN_PRESETS.map((p) => ({
name: p.name, name: p.name,
author: p.author, author: p.author,
description: p.description, description: p.description,
content: p.content, content: p.content,
originalFile: p.originalFile ?? null,
isBuiltIn: true, isBuiltIn: true,
}, folderPath: 'root/embedded/Synth-C',
})),
...EMBEDDED_LIBRARY.map((p) => ({
name: p.name,
author: p.author,
description: p.description,
content: p.content,
originalFile: p.originalFile ?? null,
isBuiltIn: true,
// the converter recorded the download nesting — normalize to the
// clean tree ("root/embedded/root/embedded/Amp Picks" → "root/embedded/Amp Picks")
folderPath: p.folder.replace('root/embedded/root/embedded/', 'root/embedded/'),
})),
],
}) })
} catch (err) {
// Two first requests racing → unique violation: the other request seeded.
if ((err as { code?: string })?.code !== 'P2002') throw err
} }
} }
export async function GET() { export async function GET() {
try { try {
await ensureSeeded() await ensureSeeded()
const presets = await db.preset.findMany({ const [presets, folders] = await Promise.all([
orderBy: [{ isBuiltIn: 'desc' }, { createdAt: 'asc' }], db.preset.findMany({
orderBy: [{ folderPath: 'asc' }, { name: 'asc' }],
select: { select: {
id: true, id: true,
name: true, name: true,
@ -33,10 +73,14 @@ export async function GET() {
description: true, description: true,
content: true, content: true,
isBuiltIn: true, isBuiltIn: true,
folderPath: true,
originalFile: true,
updatedAt: true, updatedAt: true,
}, },
}) }),
return NextResponse.json({ presets }) db.folder.findMany({ orderBy: { path: 'asc' }, select: { path: true } }),
])
return NextResponse.json({ presets, folders: folders.map((f) => f.path) })
} catch (err) { } catch (err) {
console.error('GET /api/presets failed:', err) console.error('GET /api/presets failed:', err)
return NextResponse.json({ error: 'Failed to load presets' }, { status: 500 }) return NextResponse.json({ error: 'Failed to load presets' }, { status: 500 })
@ -46,32 +90,36 @@ export async function GET() {
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const body = await req.json() const body = await req.json()
const name = typeof body.name === 'string' ? body.name.trim() : '' const name = isValidName(body?.name)
const content = typeof body.content === 'string' ? body.content : '' const content = typeof body?.content === 'string' ? body.content : ''
const author = typeof body.author === 'string' ? body.author.trim() : '' const author = typeof body?.author === 'string' ? body.author.trim().slice(0, 120) : ''
const description = typeof body.description === 'string' ? body.description.trim() : '' const description = typeof body?.description === 'string' ? body.description.trim().slice(0, 500) : ''
const folder = normalizeFolderPath(body?.folderPath ?? 'root/user created')
if (!name || !content) { if (!name || !content) {
return NextResponse.json({ error: 'name and content are required' }, { status: 400 }) return NextResponse.json({ error: 'name and content are required' }, { status: 400 })
} }
if (!folder) {
const existing = await db.preset.findUnique({ where: { name } }) return NextResponse.json({ error: 'Invalid folder path' }, { status: 400 })
if (existing?.isBuiltIn) { }
const folderRow = await db.folder.findUnique({ where: { path: folder.path } })
if (!folderRow) {
// tolerate missing ancestor rows (e.g. very old data) by creating them
await ensureFolderRows([folder.path])
}
const clash = await db.preset.findFirst({
where: { folderPath: folder.path, name },
select: { id: true },
})
if (clash) {
return NextResponse.json( return NextResponse.json(
{ error: `'${name}' is a built-in preset — save under a different name` }, { error: `A preset named "${name}" already exists in ${folder.path}` },
{ status: 409 } { status: 409 },
) )
} }
if (existing) {
const updated = await db.preset.update({
where: { id: existing.id },
data: { content, author, description },
})
return NextResponse.json({ preset: updated, updated: true })
}
const preset = await db.preset.create({ const preset = await db.preset.create({
data: { name, content, author, description, isBuiltIn: false }, data: { name, content, author, description, folderPath: folder.path, isBuiltIn: false },
}) })
return NextResponse.json({ preset, created: true }) return NextResponse.json({ preset, created: true }, { status: 201 })
} catch (err) { } catch (err) {
console.error('POST /api/presets failed:', err) console.error('POST /api/presets failed:', err)
return NextResponse.json({ error: 'Failed to save preset' }, { status: 500 }) return NextResponse.json({ error: 'Failed to save preset' }, { status: 500 })

View file

@ -23,7 +23,9 @@ import {
lsGetString, lsGetString,
readStoredBandConfig, readStoredBandConfig,
readStoredBeatConfig, readStoredBeatConfig,
restoreRandomSettings,
useAvaStore, useAvaStore,
type PresetItem,
} from '@/lib/store' } from '@/lib/store'
/** /**
@ -89,8 +91,10 @@ function useInitialLoad() {
if (cancelled) return if (cancelled) return
const store = useAvaStore.getState() const store = useAvaStore.getState()
const presetsList = (presetsRes?.presets ?? []) as { content?: string }[] const presetsList = (presetsRes?.presets ?? []) as PresetItem[]
if (presetsRes?.presets) store.setPresets(presetsRes.presets) if (presetsRes?.presets) store.setPresets(presetsRes.presets)
const folders: string[] = presetsRes?.folders ?? []
if (folders.length) store.setFolders(folders)
const settings = (settingsRes ?? {}) as { const settings = (settingsRes ?? {}) as {
defaultScript?: string | null defaultScript?: string | null
@ -103,12 +107,21 @@ function useInitialLoad() {
const beat = isBeatConfig(settings.beatConfig) ? settings.beatConfig : readStoredBeatConfig() const beat = isBeatConfig(settings.beatConfig) ? settings.beatConfig : readStoredBeatConfig()
if (beat) store.applyBeatConfig(beat) if (beat) store.applyBeatConfig(beat)
// startup script: saved default → localStorage backup → first preset → template // startup script: saved default → localStorage backup → first classic → template
let text: string | null = typeof settings.defaultScript === 'string' ? settings.defaultScript : null let text: string | null = typeof settings.defaultScript === 'string' ? settings.defaultScript : null
if (!text) text = lsGetString('ava.defaultScript') if (!text) text = lsGetString('ava.defaultScript')
if (!text) text = presetsList[0]?.content ?? null let startupPreset: PresetItem | undefined
if (!text) {
startupPreset =
presetsList.find((p) => p.folderPath === 'root/embedded/Synth-C') ?? presetsList[0]
text = startupPreset?.content ?? null
}
if (!text) text = STARTER_TEMPLATE if (!text) text = STARTER_TEMPLATE
applyScriptText(text) applyScriptText(text)
if (startupPreset) store.setActivePresetId(startupPreset.id)
// club mode: bring back the random-preset timer if it was on
restoreRandomSettings(folders.length ? folders : store.folders)
})() })()
return () => { return () => {
cancelled = true cancelled = true

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more