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:
parent
eb2b054619
commit
df39d16b61
203 changed files with 7278 additions and 276 deletions
86
agent-ctx/10-presets-ui.md
Normal file
86
agent-ctx/10-presets-ui.md
Normal 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.
|
||||
BIN
db/custom.db
BIN
db/custom.db
Binary file not shown.
|
|
@ -7,15 +7,32 @@ datasource db {
|
|||
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 {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
name String
|
||||
author String @default("")
|
||||
description String @default("")
|
||||
content String
|
||||
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())
|
||||
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 {
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
131
src/app/api/folders/route.ts
Normal file
131
src/app/api/folders/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
import { isValidName } from '@/lib/server/preset-tree'
|
||||
|
||||
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 }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
|
|
@ -11,14 +13,29 @@ export async function PUT(req: NextRequest, { params }: { params: Promise<{ id:
|
|||
if (!preset) {
|
||||
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> = {}
|
||||
if (typeof body.name === 'string' && body.name.trim()) data.name = body.name.trim()
|
||||
if (typeof body.content === 'string') data.content = body.content
|
||||
if (typeof body.author === 'string') data.author = body.author.trim()
|
||||
if (typeof body.description === 'string') data.description = body.description.trim()
|
||||
if (typeof body?.name === 'string' && body.name.trim() && body.name.trim() !== preset.name) {
|
||||
const name = isValidName(body.name)
|
||||
if (!name) {
|
||||
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 })
|
||||
return NextResponse.json({ preset: updated })
|
||||
} 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 }> }) {
|
||||
try {
|
||||
const { id } = await params
|
||||
|
|
@ -34,9 +52,6 @@ export async function DELETE(_req: NextRequest, { params }: { params: Promise<{
|
|||
if (!preset) {
|
||||
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 } })
|
||||
return NextResponse.json({ deleted: true })
|
||||
} catch (err) {
|
||||
|
|
|
|||
69
src/app/api/presets/export/route.ts
Normal file
69
src/app/api/presets/export/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
125
src/app/api/presets/import/route.ts
Normal file
125
src/app/api/presets/import/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +1,71 @@
|
|||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { db } from '@/lib/db'
|
||||
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'
|
||||
|
||||
/** 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> {
|
||||
const count = await db.preset.count()
|
||||
if (count > 0) return
|
||||
for (const p of BUILTIN_PRESETS) {
|
||||
await db.preset.create({
|
||||
data: {
|
||||
const folderCount = await db.folder.count()
|
||||
if (folderCount > 0) return
|
||||
try {
|
||||
await db.folder.createMany({ data: BASE_FOLDERS.map((path) => ({ path })) })
|
||||
// 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,
|
||||
author: p.author,
|
||||
description: p.description,
|
||||
content: p.content,
|
||||
originalFile: p.originalFile ?? null,
|
||||
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() {
|
||||
try {
|
||||
await ensureSeeded()
|
||||
const presets = await db.preset.findMany({
|
||||
orderBy: [{ isBuiltIn: 'desc' }, { createdAt: 'asc' }],
|
||||
const [presets, folders] = await Promise.all([
|
||||
db.preset.findMany({
|
||||
orderBy: [{ folderPath: 'asc' }, { name: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
|
|
@ -33,10 +73,14 @@ export async function GET() {
|
|||
description: true,
|
||||
content: true,
|
||||
isBuiltIn: true,
|
||||
folderPath: true,
|
||||
originalFile: 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) {
|
||||
console.error('GET /api/presets failed:', err)
|
||||
return NextResponse.json({ error: 'Failed to load presets' }, { status: 500 })
|
||||
|
|
@ -46,32 +90,36 @@ export async function GET() {
|
|||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
||||
const content = typeof body.content === 'string' ? body.content : ''
|
||||
const author = typeof body.author === 'string' ? body.author.trim() : ''
|
||||
const description = typeof body.description === 'string' ? body.description.trim() : ''
|
||||
const name = isValidName(body?.name)
|
||||
const content = typeof body?.content === 'string' ? body.content : ''
|
||||
const author = typeof body?.author === 'string' ? body.author.trim().slice(0, 120) : ''
|
||||
const description = typeof body?.description === 'string' ? body.description.trim().slice(0, 500) : ''
|
||||
const folder = normalizeFolderPath(body?.folderPath ?? 'root/user created')
|
||||
if (!name || !content) {
|
||||
return NextResponse.json({ error: 'name and content are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const existing = await db.preset.findUnique({ where: { name } })
|
||||
if (existing?.isBuiltIn) {
|
||||
if (!folder) {
|
||||
return NextResponse.json({ error: 'Invalid folder path' }, { status: 400 })
|
||||
}
|
||||
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(
|
||||
{ error: `'${name}' is a built-in preset — save under a different name` },
|
||||
{ status: 409 }
|
||||
{ error: `A preset named "${name}" already exists in ${folder.path}` },
|
||||
{ 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({
|
||||
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) {
|
||||
console.error('POST /api/presets failed:', err)
|
||||
return NextResponse.json({ error: 'Failed to save preset' }, { status: 500 })
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ import {
|
|||
lsGetString,
|
||||
readStoredBandConfig,
|
||||
readStoredBeatConfig,
|
||||
restoreRandomSettings,
|
||||
useAvaStore,
|
||||
type PresetItem,
|
||||
} from '@/lib/store'
|
||||
|
||||
/**
|
||||
|
|
@ -89,8 +91,10 @@ function useInitialLoad() {
|
|||
if (cancelled) return
|
||||
|
||||
const store = useAvaStore.getState()
|
||||
const presetsList = (presetsRes?.presets ?? []) as { content?: string }[]
|
||||
const presetsList = (presetsRes?.presets ?? []) as PresetItem[]
|
||||
if (presetsRes?.presets) store.setPresets(presetsRes.presets)
|
||||
const folders: string[] = presetsRes?.folders ?? []
|
||||
if (folders.length) store.setFolders(folders)
|
||||
|
||||
const settings = (settingsRes ?? {}) as {
|
||||
defaultScript?: string | null
|
||||
|
|
@ -103,12 +107,21 @@ function useInitialLoad() {
|
|||
const beat = isBeatConfig(settings.beatConfig) ? settings.beatConfig : readStoredBeatConfig()
|
||||
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
|
||||
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
|
||||
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 () => {
|
||||
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
Loading…
Reference in a new issue