v6.2 — read-only embedded library + per-visitor browser presets (anti-spam/anti-vandalism hardening)
BIN
db/custom.db
|
|
@ -1,131 +1,28 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
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'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
/** Create a subfolder inside an existing folder. Body: { path: "root/a/b" } */
|
const READ_ONLY_MSG =
|
||||||
export async function POST(req: NextRequest) {
|
'The server folder tree is read-only. Your folders live in your browser under "user created".'
|
||||||
try {
|
|
||||||
const body = await req.json()
|
/**
|
||||||
const folder = normalizeFolderPath(body?.path)
|
* READ-ONLY tree (v6.2): no folder on the shared server may be created,
|
||||||
if (!folder) {
|
* renamed, or deleted through the public API — otherwise one visitor could
|
||||||
return NextResponse.json({ error: 'Invalid folder path' }, { status: 400 })
|
* restructure (or wipe) the embedded library for everybody. Each visitor's
|
||||||
}
|
* own folders are managed entirely inside their browser.
|
||||||
if (folder.path === 'root') {
|
*/
|
||||||
return NextResponse.json({ error: '"root" already exists' }, { status: 409 })
|
export async function POST(_req: NextRequest) {
|
||||||
}
|
return NextResponse.json({ error: READ_ONLY_MSG }, { status: 403 })
|
||||||
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 PUT(_req: NextRequest) {
|
||||||
export async function PATCH(req: NextRequest) {
|
return NextResponse.json({ error: READ_ONLY_MSG }, { status: 403 })
|
||||||
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) => {
|
export async function PATCH(_req: NextRequest) {
|
||||||
const folders = await tx.folder.findMany({ where: subtreeFolderFilter(folder.path) })
|
return NextResponse.json({ error: READ_ONLY_MSG }, { status: 403 })
|
||||||
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) {
|
||||||
export async function DELETE(req: NextRequest) {
|
return NextResponse.json({ error: READ_ONLY_MSG }, { status: 403 })
|
||||||
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,61 +1,22 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
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). */
|
const READ_ONLY_MSG =
|
||||||
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
'The server preset library is read-only. Your changes are saved in your browser under "user created".'
|
||||||
try {
|
|
||||||
const { id } = await params
|
/**
|
||||||
const body = await req.json()
|
* READ-ONLY library (v6.2): the embedded presets are shared by every visitor
|
||||||
const preset = await db.preset.findUnique({ where: { id } })
|
* and can never be modified through the API. Editing happens on a visitor's
|
||||||
if (!preset) {
|
* own copy in their browser (localStorage, merged into the tree client-side).
|
||||||
return NextResponse.json({ error: 'Preset not found' }, { status: 404 })
|
*/
|
||||||
|
export async function PUT(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
void params
|
||||||
|
return NextResponse.json({ error: READ_ONLY_MSG }, { status: 403 })
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: Record<string, string> = {}
|
/** Same policy — embedded presets cannot be deleted through the API. */
|
||||||
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) {
|
|
||||||
console.error('PUT /api/presets/[id] failed:', err)
|
|
||||||
return NextResponse.json({ error: 'Failed to update preset' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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 {
|
void params
|
||||||
const { id } = await params
|
return NextResponse.json({ error: READ_ONLY_MSG }, { status: 403 })
|
||||||
const preset = await db.preset.findUnique({ where: { id } })
|
|
||||||
if (!preset) {
|
|
||||||
return NextResponse.json({ error: 'Preset not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
await db.preset.delete({ where: { id } })
|
|
||||||
return NextResponse.json({ deleted: true })
|
|
||||||
} catch (err) {
|
|
||||||
console.error('DELETE /api/presets/[id] failed:', err)
|
|
||||||
return NextResponse.json({ error: 'Failed to delete preset' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,125 +1,18 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
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'
|
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
|
* READ-ONLY library (v6.2): zips are unpacked IN THE VISITOR'S BROWSER and
|
||||||
* under "root/…" land at exactly that path; anything else is placed inside
|
* land only in that visitor's "user created" area (localStorage). Nothing is
|
||||||
* the target folder selected in the UI. Only .ava (and .txt) script files
|
* ever uploaded to the server, so an import can never affect other visitors.
|
||||||
* are imported — other files are counted and skipped.
|
|
||||||
*
|
|
||||||
* POST /api/presets/import (multipart/form-data: file, targetPath)
|
|
||||||
*/
|
*/
|
||||||
export async function POST(req: NextRequest) {
|
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(
|
return NextResponse.json(
|
||||||
{ error: 'No .ava preset files found in that zip' },
|
{
|
||||||
{ status: 400 },
|
error:
|
||||||
|
'Server-side import is disabled. Zips are imported in your browser into "user created" — nothing is uploaded.',
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,16 @@ 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, EMBEDDED_SEED_VERSION } from '@/lib/ava/embedded-presets'
|
import { EMBEDDED_LIBRARY, EMBEDDED_SEED_VERSION } from '@/lib/ava/embedded-presets'
|
||||||
import { ensureFolderRows, isValidName, normalizeFolderPath } from '@/lib/server/preset-tree'
|
import { ensureFolderRows } from '@/lib/server/preset-tree'
|
||||||
|
import { EMBEDDED_ROOT, ROOT, USER_ROOT } from '@/lib/preset-paths'
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
export const dynamic = 'force-dynamic'
|
||||||
|
|
||||||
/** The structural backbone of the tree, created on first run. */
|
/** The structural backbone of the tree, created on first run. */
|
||||||
const BASE_FOLDERS = [
|
const BASE_FOLDERS = [
|
||||||
'root',
|
ROOT,
|
||||||
'root/embedded',
|
EMBEDDED_ROOT,
|
||||||
'root/user created',
|
USER_ROOT,
|
||||||
'root/embedded/Synth-C',
|
'root/embedded/Synth-C',
|
||||||
'root/embedded/Amp Picks',
|
'root/embedded/Amp Picks',
|
||||||
'root/embedded/Dynamics',
|
'root/embedded/Dynamics',
|
||||||
|
|
@ -18,27 +19,53 @@ const BASE_FOLDERS = [
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Version-gated seed: folder backbone + the full embedded library
|
* Version-gated seed + INTEGRITY SELF-HEAL for the embedded library.
|
||||||
* (5 hand-ported classics + 90 Winamp AVS presets from the user's Google
|
|
||||||
* Drive collection, organized as Synth-C / Amp Picks / Dynamics / Envision).
|
|
||||||
*
|
*
|
||||||
* The embedded subtree (root/embedded/**) refreshes automatically whenever
|
* The server library is the single source of truth for what every visitor
|
||||||
* EMBEDDED_SEED_VERSION changes (i.e. the library was regenerated) — user
|
* sees, so GET verifies it on every request:
|
||||||
* presets outside root/embedded are never touched.
|
* 1. version gate — EMBEDDED_SEED_VERSION changes trigger a full re-seed
|
||||||
|
* (library was regenerated);
|
||||||
|
* 2. fingerprint — the embedded subtree must exactly match
|
||||||
|
* BUILTIN + EMBEDDED (folder + name). Any drift — a preset renamed or
|
||||||
|
* deleted through pre-hardening endpoints, a row injected straight into
|
||||||
|
* the DB — re-seeds the subtree automatically.
|
||||||
*/
|
*/
|
||||||
|
function libraryFingerprint(): string {
|
||||||
|
const items = [
|
||||||
|
...BUILTIN_PRESETS.map((p) => `root/embedded/Synth-C/${p.name}`),
|
||||||
|
...EMBEDDED_LIBRARY.map(
|
||||||
|
(p) => `${p.folder.replace('root/embedded/root/embedded/', 'root/embedded/')}/${p.name}`,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
return items.sort().join('|')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function currentFingerprint(): Promise<string> {
|
||||||
|
const rows = await db.preset.findMany({
|
||||||
|
where: { OR: [{ folderPath: EMBEDDED_ROOT }, { folderPath: { startsWith: 'root/embedded/' } }] },
|
||||||
|
select: { folderPath: true, name: true },
|
||||||
|
})
|
||||||
|
return rows.map((r) => `${r.folderPath}/${r.name}`).sort().join('|')
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureSeeded(): Promise<void> {
|
async function ensureSeeded(): Promise<void> {
|
||||||
const settings = await db.settings.findUnique({
|
const settings = await db.settings.findUnique({
|
||||||
where: { id: 'main' },
|
where: { id: 'main' },
|
||||||
select: { embeddedSeed: true },
|
select: { embeddedSeed: true },
|
||||||
})
|
})
|
||||||
if (settings?.embeddedSeed === EMBEDDED_SEED_VERSION) return
|
if (settings?.embeddedSeed === EMBEDDED_SEED_VERSION) {
|
||||||
|
// version marker matches — still verify the subtree is intact (heals any
|
||||||
|
// pre-hardening rename / delete / injection even without a version bump)
|
||||||
|
if ((await currentFingerprint()) === libraryFingerprint()) return
|
||||||
|
console.warn('[presets] embedded library drift detected — re-seeding from source')
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await ensureFolderRows(BASE_FOLDERS)
|
await ensureFolderRows(BASE_FOLDERS)
|
||||||
// refresh the embedded library subtree (+ legacy stray built-ins)
|
// refresh the embedded library subtree (+ legacy stray built-ins)
|
||||||
await db.preset.deleteMany({
|
await db.preset.deleteMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
{ folderPath: 'root/embedded' },
|
{ folderPath: EMBEDDED_ROOT },
|
||||||
{ folderPath: { startsWith: 'root/embedded/' } },
|
{ folderPath: { startsWith: 'root/embedded/' } },
|
||||||
{ isBuiltIn: true },
|
{ isBuiltIn: true },
|
||||||
],
|
],
|
||||||
|
|
@ -80,12 +107,52 @@ async function ensureSeeded(): Promise<void> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SELF-HEAL (v6.2 security): the server library is READ-ONLY for visitors.
|
||||||
|
* Anything a visitor managed to write before this hardening — presets outside
|
||||||
|
* the embedded library, folders outside the structural backbone — is purged
|
||||||
|
* here so no visitor can ever alter what other visitors see. Runs on every
|
||||||
|
* GET; it's two indexed deleteMany calls on a small SQLite table.
|
||||||
|
*/
|
||||||
|
async function purgeVisitorJunk(): Promise<void> {
|
||||||
|
await db.preset.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ isBuiltIn: false },
|
||||||
|
{ folderPath: USER_ROOT },
|
||||||
|
{ folderPath: { startsWith: 'root/user created/' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await db.folder.deleteMany({
|
||||||
|
where: {
|
||||||
|
NOT: [
|
||||||
|
{ path: ROOT },
|
||||||
|
{ path: EMBEDDED_ROOT },
|
||||||
|
{ path: USER_ROOT },
|
||||||
|
{ path: { startsWith: 'root/embedded/' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The embedded library, read-only. Visitors' own presets live in their
|
||||||
|
* browser (localStorage) and are merged client-side — nothing per-visitor
|
||||||
|
* is stored on, or served from, this endpoint.
|
||||||
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
await ensureSeeded()
|
await ensureSeeded()
|
||||||
const [presets, folders] = await Promise.all([
|
await purgeVisitorJunk()
|
||||||
|
const [presets, backbone, embeddedSubfolders] = await Promise.all([
|
||||||
db.preset.findMany({
|
db.preset.findMany({
|
||||||
orderBy: [{ folderPath: 'asc' }, { name: 'asc' }],
|
orderBy: [{ folderPath: 'asc' }, { name: 'asc' }],
|
||||||
|
where: {
|
||||||
|
// defense in depth: only ever serve the embedded library
|
||||||
|
isBuiltIn: true,
|
||||||
|
OR: [{ folderPath: EMBEDDED_ROOT }, { folderPath: { startsWith: 'root/embedded/' } }],
|
||||||
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
|
@ -99,50 +166,41 @@ export async function GET() {
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
db.folder.findMany({ orderBy: { path: 'asc' }, select: { path: true } }),
|
db.folder.findMany({
|
||||||
|
orderBy: { path: 'asc' },
|
||||||
|
where: {
|
||||||
|
path: {
|
||||||
|
in: [ROOT, EMBEDDED_ROOT, USER_ROOT],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { path: true },
|
||||||
|
}),
|
||||||
|
// embedded subfolders (folder rows under root/embedded/)
|
||||||
|
db.folder.findMany({
|
||||||
|
orderBy: { path: 'asc' },
|
||||||
|
where: { path: { startsWith: 'root/embedded/' } },
|
||||||
|
select: { path: true },
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
return NextResponse.json({ presets, folders: folders.map((f) => f.path) })
|
const folderPaths = [...new Set([...backbone.map((f) => f.path), ...embeddedSubfolders.map((f) => f.path)])]
|
||||||
|
return NextResponse.json({ presets, folders: folderPaths })
|
||||||
} 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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
/**
|
||||||
try {
|
* READ-ONLY library (v6.2): preset creation happens exclusively in the
|
||||||
const body = await req.json()
|
* visitor's browser ("user created" area, localStorage). The server accepts
|
||||||
const name = isValidName(body?.name)
|
* no writes, so nobody can save anything other visitors would see.
|
||||||
const content = typeof body?.content === 'string' ? body.content : ''
|
*/
|
||||||
const author = typeof body?.author === 'string' ? body.author.trim().slice(0, 120) : ''
|
export async function POST(_req: NextRequest) {
|
||||||
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 })
|
|
||||||
}
|
|
||||||
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(
|
return NextResponse.json(
|
||||||
{ error: `A preset named "${name}" already exists in ${folder.path}` },
|
{
|
||||||
{ status: 409 },
|
error:
|
||||||
|
'The server preset library is read-only. Your presets are saved in your browser under "user created".',
|
||||||
|
},
|
||||||
|
{ status: 403 },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const preset = await db.preset.create({
|
|
||||||
data: { name, content, author, description, folderPath: folder.path, isBuiltIn: false },
|
|
||||||
})
|
|
||||||
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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { db } from '@/lib/db'
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
|
|
||||||
export async function GET() {
|
|
||||||
try {
|
|
||||||
let settings = await db.settings.findUnique({ where: { id: 'main' } })
|
|
||||||
if (!settings) {
|
|
||||||
settings = await db.settings.create({ data: { id: 'main' } })
|
|
||||||
}
|
|
||||||
return NextResponse.json({
|
|
||||||
defaultScript: settings.defaultScript ?? null,
|
|
||||||
bandConfig: settings.bandConfig ? JSON.parse(settings.bandConfig) : null,
|
|
||||||
beatConfig: settings.beatConfig ? JSON.parse(settings.beatConfig) : null,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error('GET /api/settings failed:', err)
|
|
||||||
return NextResponse.json({ error: 'Failed to load settings' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function PUT(req: NextRequest) {
|
|
||||||
try {
|
|
||||||
const body = await req.json()
|
|
||||||
const data: Record<string, string> = {}
|
|
||||||
if (typeof body.defaultScript === 'string') data.defaultScript = body.defaultScript
|
|
||||||
if (body.bandConfig && typeof body.bandConfig === 'object') {
|
|
||||||
data.bandConfig = JSON.stringify(body.bandConfig)
|
|
||||||
}
|
|
||||||
if (body.beatConfig && typeof body.beatConfig === 'object') {
|
|
||||||
data.beatConfig = JSON.stringify(body.beatConfig)
|
|
||||||
}
|
|
||||||
const settings = await db.settings.upsert({
|
|
||||||
where: { id: 'main' },
|
|
||||||
update: data,
|
|
||||||
create: { id: 'main', ...data },
|
|
||||||
})
|
|
||||||
return NextResponse.json({
|
|
||||||
defaultScript: settings.defaultScript ?? null,
|
|
||||||
bandConfig: settings.bandConfig ? JSON.parse(settings.bandConfig) : null,
|
|
||||||
beatConfig: settings.beatConfig ? JSON.parse(settings.beatConfig) : null,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
console.error('PUT /api/settings failed:', err)
|
|
||||||
return NextResponse.json({ error: 'Failed to save settings' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// AVA — Detect tab: fine-tune the three analysis bands (Hz ranges + gains)
|
// OVE — Detect tab: fine-tune the three analysis bands (Hz ranges + gains)
|
||||||
// with live VU meters. Changes apply to the engine immediately and persist
|
// with live VU meters. Changes apply to the engine immediately and persist
|
||||||
// (localStorage instantly, /api/settings debounced).
|
// to this browser's localStorage.
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,36 @@
|
||||||
// AVA — Presets tab: folder/file tree manager (Task 10 redesign).
|
// OVE — Presets tab: folder/file tree manager (v6.2 security model).
|
||||||
//
|
//
|
||||||
// Left column: the preset library tree — folders expand/collapse (chevron
|
// The tree merges two worlds:
|
||||||
// only), rows select, double-clicking a preset runs it, the running preset
|
// • root/embedded — the shipped library, READ-ONLY for every visitor (lock
|
||||||
// is flagged emerald, and originals download straight from the row.
|
// badge in the tree; the server refuses all writes). Selecting an embedded
|
||||||
// Right column: context-sensitive management (load / create / rename /
|
// preset offers "Copy to my presets" instead of rename/delete.
|
||||||
// delete / export .zip / import .zip) plus the club-mode randomizer — the
|
// • root/user created — this visitor's OWN presets, stored in their browser
|
||||||
// timer itself lives in the store, this tab only wires the controls.
|
// (localStorage). Create / rename / delete / import all happen locally —
|
||||||
|
// nothing is sent to the server, so nobody can spam or vandalize what
|
||||||
|
// other visitors see.
|
||||||
|
//
|
||||||
|
// Left column: the tree (chevron expands, row selects, double-click runs,
|
||||||
|
// running preset flagged emerald, originals download from the row).
|
||||||
|
// Right column: context-sensitive management + the club-mode randomizer.
|
||||||
|
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
CopyPlus,
|
||||||
Download,
|
Download,
|
||||||
FilePlus2,
|
FilePlus2,
|
||||||
FileText,
|
FileText,
|
||||||
Folder,
|
Folder,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
|
Lock,
|
||||||
Pencil,
|
Pencil,
|
||||||
Play,
|
Play,
|
||||||
Trash2,
|
Trash2,
|
||||||
Upload,
|
Upload,
|
||||||
|
User,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
|
|
@ -47,14 +56,22 @@ import {
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { STARTER_TEMPLATE } from '@/lib/ava/presets'
|
import { STARTER_TEMPLATE } from '@/lib/ava/presets'
|
||||||
import { refreshPresets, useAvaStore, type PresetItem } from '@/lib/store'
|
import { applyUserPresets, refreshPresets, useAvaStore, type PresetItem } from '@/lib/store'
|
||||||
import { applyScriptText } from '@/components/ava/audio-source'
|
import { applyScriptText } from '@/components/ava/audio-source'
|
||||||
import { thinScrollbar } from '@/components/ava/shared'
|
import { thinScrollbar } from '@/components/ava/shared'
|
||||||
import { useToast } from '@/hooks/use-toast'
|
import { useToast } from '@/hooks/use-toast'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { ROOT, USER_ROOT, isEmbeddedPath, isUserAreaPath } from '@/lib/preset-paths'
|
||||||
/** The tree backbone — the backend refuses to rename or delete these. */
|
import {
|
||||||
const PROTECTED_FOLDERS = new Set(['root', 'root/embedded', 'root/user created'])
|
createUserFolder,
|
||||||
|
createUserPreset,
|
||||||
|
deleteUserFolder,
|
||||||
|
deleteUserPreset,
|
||||||
|
presetIsLocal,
|
||||||
|
renameUserFolder,
|
||||||
|
renameUserPreset,
|
||||||
|
} from '@/lib/ava/user-presets'
|
||||||
|
import { exportPresetsZip, importPresetsZip } from '@/lib/ava/preset-io'
|
||||||
|
|
||||||
/** Same default-expansion rule as the store: paths with ≤ 2 segments open. */
|
/** Same default-expansion rule as the store: paths with ≤ 2 segments open. */
|
||||||
function defaultOpen(path: string): boolean {
|
function defaultOpen(path: string): boolean {
|
||||||
|
|
@ -171,6 +188,7 @@ function useLoadPreset() {
|
||||||
function TreeFolder({ node }: { node: FolderNode }) {
|
function TreeFolder({ node }: { node: FolderNode }) {
|
||||||
const isOpen = useAvaStore((s) => s.expandedFolders[node.path] ?? defaultOpen(node.path))
|
const isOpen = useAvaStore((s) => s.expandedFolders[node.path] ?? defaultOpen(node.path))
|
||||||
const selected = useAvaStore((s) => s.selectedFolder === node.path)
|
const selected = useAvaStore((s) => s.selectedFolder === node.path)
|
||||||
|
const readOnly = node.path === ROOT || isEmbeddedPath(node.path)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -199,7 +217,7 @@ function TreeFolder({ node }: { node: FolderNode }) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => useAvaStore.getState().selectFolder(node.path)}
|
onClick={() => useAvaStore.getState().selectFolder(node.path)}
|
||||||
title={node.path}
|
title={readOnly ? `${node.path} — read-only (shipped with OVE)` : node.path}
|
||||||
className="flex h-9 min-w-0 flex-1 items-center gap-1.5 py-1 pr-1 text-left"
|
className="flex h-9 min-w-0 flex-1 items-center gap-1.5 py-1 pr-1 text-left"
|
||||||
>
|
>
|
||||||
{isOpen ? (
|
{isOpen ? (
|
||||||
|
|
@ -212,6 +230,15 @@ function TreeFolder({ node }: { node: FolderNode }) {
|
||||||
>
|
>
|
||||||
{node.name}
|
{node.name}
|
||||||
</span>
|
</span>
|
||||||
|
{readOnly && (
|
||||||
|
<span
|
||||||
|
title="Read-only — the shipped library is the same for every visitor"
|
||||||
|
aria-label="Read-only folder"
|
||||||
|
className="shrink-0 text-zinc-600"
|
||||||
|
>
|
||||||
|
<Lock className="size-3" aria-hidden />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
title={`${node.subtreeCount} presets in this folder and below`}
|
title={`${node.subtreeCount} presets in this folder and below`}
|
||||||
|
|
@ -231,10 +258,15 @@ function TreeFolder({ node }: { node: FolderNode }) {
|
||||||
))}
|
))}
|
||||||
{node.children.length === 0 && node.presets.length === 0 && (
|
{node.children.length === 0 && node.presets.length === 0 && (
|
||||||
<p
|
<p
|
||||||
className="flex h-8 items-center text-[11px] italic text-zinc-600"
|
className={cn(
|
||||||
|
'flex h-8 items-center text-[11px] italic',
|
||||||
|
node.path === USER_ROOT ? 'text-zinc-500' : 'text-zinc-600',
|
||||||
|
)}
|
||||||
style={{ paddingLeft: (node.depth + 1) * 10 + 18 }}
|
style={{ paddingLeft: (node.depth + 1) * 10 + 18 }}
|
||||||
>
|
>
|
||||||
(empty)
|
{node.path === USER_ROOT
|
||||||
|
? 'yours — create or import presets (stored in this browser)'
|
||||||
|
: '(empty)'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -247,6 +279,7 @@ function TreePreset({ preset, depth }: { preset: PresetItem; depth: number }) {
|
||||||
const loadPreset = useLoadPreset()
|
const loadPreset = useLoadPreset()
|
||||||
const selected = useAvaStore((s) => s.selectedPresetId === preset.id)
|
const selected = useAvaStore((s) => s.selectedPresetId === preset.id)
|
||||||
const running = useAvaStore((s) => s.activePresetId === preset.id)
|
const running = useAvaStore((s) => s.activePresetId === preset.id)
|
||||||
|
const own = presetIsLocal(preset.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -267,7 +300,7 @@ function TreePreset({ preset, depth }: { preset: PresetItem; depth: number }) {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => useAvaStore.getState().selectPreset(preset.id)}
|
onClick={() => useAvaStore.getState().selectPreset(preset.id)}
|
||||||
onDoubleClick={() => loadPreset(preset)}
|
onDoubleClick={() => loadPreset(preset)}
|
||||||
title={`${preset.name} (double-click to run)`}
|
title={`${preset.name} (double-click to run)${own ? ' — yours (this browser)' : ''}`}
|
||||||
className="flex h-9 min-w-0 flex-1 items-center gap-1.5 py-1 pr-1 text-left"
|
className="flex h-9 min-w-0 flex-1 items-center gap-1.5 py-1 pr-1 text-left"
|
||||||
>
|
>
|
||||||
{running ? (
|
{running ? (
|
||||||
|
|
@ -286,6 +319,15 @@ function TreePreset({ preset, depth }: { preset: PresetItem; depth: number }) {
|
||||||
≈
|
≈
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{own && (
|
||||||
|
<span
|
||||||
|
title="Your preset — stored in this browser only"
|
||||||
|
aria-label="Your preset, stored in this browser"
|
||||||
|
className="shrink-0 text-emerald-400/60"
|
||||||
|
>
|
||||||
|
<User className="size-3" aria-hidden />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
{preset.originalFile && (
|
{preset.originalFile && (
|
||||||
<a
|
<a
|
||||||
|
|
@ -336,7 +378,7 @@ function NameDialog({
|
||||||
onError: (error: string | null) => void
|
onError: (error: string | null) => void
|
||||||
submitLabel: string
|
submitLabel: string
|
||||||
busy: boolean
|
busy: boolean
|
||||||
/** Return true to close the dialog (server errors keep it open). */
|
/** Return true to close the dialog (errors keep it open). */
|
||||||
onSubmit: (name: string) => Promise<boolean>
|
onSubmit: (name: string) => Promise<boolean>
|
||||||
}) {
|
}) {
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
|
|
@ -424,7 +466,7 @@ export function PresetsTab() {
|
||||||
const [delayError, setDelayError] = useState(false)
|
const [delayError, setDelayError] = useState(false)
|
||||||
const importInputRef = useRef<HTMLInputElement>(null)
|
const importInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
// freshness refresh whenever the tab mounts (initial seed happens on app load)
|
// freshness refresh whenever the tab mounts (initial merge happens on app load)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshPresets()
|
void refreshPresets()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
@ -439,7 +481,12 @@ export function PresetsTab() {
|
||||||
() => presets.find((p) => p.id === selectedPresetId) ?? null,
|
() => presets.find((p) => p.id === selectedPresetId) ?? null,
|
||||||
[presets, selectedPresetId],
|
[presets, selectedPresetId],
|
||||||
)
|
)
|
||||||
const folderProtected = PROTECTED_FOLDERS.has(selectedFolder)
|
|
||||||
|
// ---- read-only vs. browser-owned areas --------------------------------
|
||||||
|
const folderReadOnly = selectedFolder === ROOT || isEmbeddedPath(selectedFolder)
|
||||||
|
const folderProtected = selectedFolder === USER_ROOT // the user area root itself
|
||||||
|
const presetOwn = selectedPreset ? presetIsLocal(selectedPreset.id) : false
|
||||||
|
|
||||||
const folderName = selectedFolder.split('/').pop() ?? selectedFolder
|
const folderName = selectedFolder.split('/').pop() ?? selectedFolder
|
||||||
const folderPrefix = `${selectedFolder}/`
|
const folderPrefix = `${selectedFolder}/`
|
||||||
const folderPresetCount = presets.filter(
|
const folderPresetCount = presets.filter(
|
||||||
|
|
@ -454,46 +501,35 @@ export function PresetsTab() {
|
||||||
const scopeLabel = randomScope === 'root' ? 'all presets' : (randomScope.split('/').pop() ?? randomScope)
|
const scopeLabel = randomScope === 'root' ? 'all presets' : (randomScope.split('/').pop() ?? randomScope)
|
||||||
|
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
// actions
|
// actions — all user-area CRUD happens in THIS browser (localStorage)
|
||||||
// -----------------------------------------------------------------
|
// -----------------------------------------------------------------
|
||||||
|
|
||||||
const submitCreatePreset = async (name: string): Promise<boolean> => {
|
const submitCreatePreset = async (name: string): Promise<boolean> => {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/presets', {
|
const res = createUserPreset({
|
||||||
method: 'POST',
|
name,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
content: STARTER_TEMPLATE,
|
||||||
body: JSON.stringify({ name, content: STARTER_TEMPLATE, folderPath: selectedFolder }),
|
folderPath: selectedFolder,
|
||||||
})
|
author: 'you',
|
||||||
const data = (await res.json().catch(() => null)) as { preset?: PresetItem; error?: string } | null
|
|
||||||
if (!res.ok || !data?.preset) {
|
|
||||||
toast({
|
|
||||||
title: 'Preset not created',
|
|
||||||
description: data?.error ?? `Server responded ${res.status}.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
})
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
toast({ title: 'Preset not created', description: res.error, variant: 'destructive' })
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
await refreshPresets()
|
applyUserPresets()
|
||||||
const st = useAvaStore.getState()
|
const st = useAvaStore.getState()
|
||||||
st.setEditTarget({ id: data.preset.id, name: data.preset.name, folderPath: data.preset.folderPath })
|
st.setEditTarget({ id: res.preset.id, name: res.preset.name, folderPath: res.preset.folderPath })
|
||||||
st.setScriptText(STARTER_TEMPLATE)
|
st.setScriptText(STARTER_TEMPLATE)
|
||||||
applyScriptText(STARTER_TEMPLATE)
|
applyScriptText(STARTER_TEMPLATE)
|
||||||
st.setActivePresetId(data.preset.id)
|
st.setActivePresetId(res.preset.id)
|
||||||
st.selectPreset(data.preset.id)
|
st.selectPreset(res.preset.id)
|
||||||
st.setTab('script')
|
st.setTab('script')
|
||||||
toast({
|
toast({
|
||||||
title: 'Preset created',
|
title: 'Preset created',
|
||||||
description: `"${data.preset.name}" created — now edit and Save.`,
|
description: `"${res.preset.name}" created in this browser — now edit and Save.`,
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: 'Preset not created',
|
|
||||||
description: 'Network error while creating the preset.',
|
|
||||||
variant: 'destructive',
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
|
|
@ -503,33 +539,17 @@ export function PresetsTab() {
|
||||||
const path = `${selectedFolder}/${name}`
|
const path = `${selectedFolder}/${name}`
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/folders', {
|
const res = createUserFolder(path)
|
||||||
method: 'POST',
|
if (!res.ok) {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
toast({ title: 'Folder not created', description: res.error, variant: 'destructive' })
|
||||||
body: JSON.stringify({ path }),
|
|
||||||
})
|
|
||||||
const data = (await res.json().catch(() => null)) as { folder?: { path: string }; error?: string } | null
|
|
||||||
if (!res.ok || !data?.folder) {
|
|
||||||
toast({
|
|
||||||
title: 'Folder not created',
|
|
||||||
description: data?.error ?? `Server responded ${res.status}.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
})
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
await refreshPresets()
|
applyUserPresets()
|
||||||
const st = useAvaStore.getState()
|
const st = useAvaStore.getState()
|
||||||
st.toggleFolder(selectedFolder, true)
|
st.toggleFolder(selectedFolder, true)
|
||||||
st.selectFolder(data.folder.path)
|
st.selectFolder(res.path)
|
||||||
toast({ title: 'Folder created', description: `"${name}" added under ${selectedFolder}.` })
|
toast({ title: 'Folder created', description: `"${name}" added under ${selectedFolder} (this browser).` })
|
||||||
return true
|
return true
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: 'Folder not created',
|
|
||||||
description: 'Network error while creating the folder.',
|
|
||||||
variant: 'destructive',
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
|
|
@ -539,71 +559,46 @@ export function PresetsTab() {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
if (selectedPreset) {
|
if (selectedPreset) {
|
||||||
const res = await fetch(`/api/presets/${selectedPreset.id}`, {
|
const res = renameUserPreset(selectedPreset.id, name)
|
||||||
method: 'PUT',
|
if (!res.ok) {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
toast({ title: 'Rename failed', description: res.error, variant: 'destructive' })
|
||||||
body: JSON.stringify({ name }),
|
|
||||||
})
|
|
||||||
const data = (await res.json().catch(() => null)) as { preset?: PresetItem; error?: string } | null
|
|
||||||
if (!res.ok || !data?.preset) {
|
|
||||||
toast({
|
|
||||||
title: 'Rename failed',
|
|
||||||
description: data?.error ?? `Server responded ${res.status}.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
})
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
await refreshPresets()
|
applyUserPresets()
|
||||||
const st = useAvaStore.getState()
|
const st = useAvaStore.getState()
|
||||||
if (st.editTarget?.id === selectedPreset.id) {
|
if (st.editTarget?.id === selectedPreset.id) {
|
||||||
st.setEditTarget({
|
st.setEditTarget({
|
||||||
id: data.preset.id,
|
id: res.preset.id,
|
||||||
name: data.preset.name,
|
name: res.preset.name,
|
||||||
folderPath: data.preset.folderPath,
|
folderPath: res.preset.folderPath,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
toast({
|
toast({
|
||||||
title: 'Preset renamed',
|
title: 'Preset renamed',
|
||||||
description: `"${selectedPreset.name}" is now "${data.preset.name}".`,
|
description: `"${selectedPreset.name}" is now "${res.preset.name}".`,
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// folder rename — everything below moves with it
|
// folder rename — everything below moves with it (browser-side)
|
||||||
const res = await fetch('/api/folders', {
|
const res = renameUserFolder(selectedFolder, name)
|
||||||
method: 'PATCH',
|
if (!res.ok) {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
toast({ title: 'Rename failed', description: res.error, variant: 'destructive' })
|
||||||
body: JSON.stringify({ path: selectedFolder, newName: name }),
|
|
||||||
})
|
|
||||||
const data = (await res.json().catch(() => null)) as { to?: string; error?: string } | null
|
|
||||||
if (!res.ok || !data?.to) {
|
|
||||||
toast({
|
|
||||||
title: 'Rename failed',
|
|
||||||
description: data?.error ?? `Server responded ${res.status}.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
})
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
await refreshPresets()
|
applyUserPresets()
|
||||||
const st = useAvaStore.getState()
|
const st = useAvaStore.getState()
|
||||||
st.selectFolder(data.to)
|
st.selectFolder(res.to)
|
||||||
if (
|
if (
|
||||||
st.editTarget &&
|
st.editTarget &&
|
||||||
(st.editTarget.folderPath === selectedFolder || st.editTarget.folderPath.startsWith(folderPrefix))
|
(st.editTarget.folderPath === selectedFolder || st.editTarget.folderPath.startsWith(folderPrefix))
|
||||||
) {
|
) {
|
||||||
st.setEditTarget({
|
st.setEditTarget({
|
||||||
...st.editTarget,
|
...st.editTarget,
|
||||||
folderPath: data.to + st.editTarget.folderPath.slice(selectedFolder.length),
|
folderPath: res.to + st.editTarget.folderPath.slice(selectedFolder.length),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
toast({ title: 'Folder renamed', description: `Now at ${data.to}.` })
|
toast({ title: 'Folder renamed', description: `Now at ${res.to}.` })
|
||||||
return true
|
return true
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: 'Rename failed',
|
|
||||||
description: 'Network error while renaming.',
|
|
||||||
variant: 'destructive',
|
|
||||||
})
|
|
||||||
return false
|
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
|
|
@ -639,31 +634,17 @@ export function PresetsTab() {
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
const isFolder = deleteTarget.kind === 'folder'
|
const isFolder = deleteTarget.kind === 'folder'
|
||||||
const res = await fetch(
|
const res = isFolder ? deleteUserFolder(deleteTarget.path) : deleteUserPreset(deleteTarget.path)
|
||||||
isFolder ? `/api/folders?path=${encodeURIComponent(deleteTarget.path)}` : `/api/presets/${deleteTarget.path}`,
|
|
||||||
{ method: 'DELETE' },
|
|
||||||
)
|
|
||||||
const data = (await res.json().catch(() => null)) as { deleted?: boolean; error?: string } | null
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
toast({
|
toast({ title: 'Delete failed', description: res.error, variant: 'destructive' })
|
||||||
title: 'Delete failed',
|
|
||||||
description: data?.error ?? `Server responded ${res.status}.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await refreshPresets()
|
applyUserPresets()
|
||||||
toast({
|
toast({
|
||||||
title: isFolder ? 'Folder deleted' : 'Preset deleted',
|
title: isFolder ? 'Folder deleted' : 'Preset deleted',
|
||||||
description: isFolder
|
description: isFolder
|
||||||
? `"${deleteTarget.name}" and everything inside it removed.`
|
? `"${deleteTarget.name}" and everything inside it removed from this browser.`
|
||||||
: `"${deleteTarget.name}" removed.`,
|
: `"${deleteTarget.name}" removed from this browser.`,
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: 'Delete failed',
|
|
||||||
description: 'Network error while deleting.',
|
|
||||||
variant: 'destructive',
|
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
|
|
@ -671,36 +652,71 @@ export function PresetsTab() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const importZip = async (file: File) => {
|
/** Copy an embedded preset into this visitor's browser library. */
|
||||||
setBusy(true)
|
const copyToMyPresets = (preset: PresetItem) => {
|
||||||
try {
|
const candidates = [preset.name, `${preset.name} (copy)`, `${preset.name} (copy 2)`, `${preset.name} (copy 3)`]
|
||||||
const form = new FormData()
|
for (const name of candidates) {
|
||||||
form.append('file', file)
|
const res = createUserPreset({
|
||||||
form.append('targetPath', selectedFolder)
|
name,
|
||||||
const res = await fetch('/api/presets/import', { method: 'POST', body: form })
|
content: preset.content,
|
||||||
const data = (await res.json().catch(() => null)) as
|
folderPath: USER_ROOT,
|
||||||
| { imported?: number; skipped?: number; foldersCreated?: number; error?: string }
|
author: preset.author,
|
||||||
| null
|
description: preset.description,
|
||||||
if (!res.ok || !data) {
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
applyUserPresets()
|
||||||
|
const st = useAvaStore.getState()
|
||||||
|
st.toggleFolder(USER_ROOT, true)
|
||||||
|
st.selectPreset(res.preset.id)
|
||||||
toast({
|
toast({
|
||||||
title: 'Import failed',
|
title: 'Copied to your presets',
|
||||||
description: data?.error ?? `Server responded ${res.status}.`,
|
description: `"${res.preset.name}" is now yours to edit — stored in this browser only.`,
|
||||||
variant: 'destructive',
|
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await refreshPresets()
|
if (!res.error.includes('already exists')) {
|
||||||
useAvaStore.getState().toggleFolder(selectedFolder, true)
|
toast({ title: 'Copy failed', description: res.error, variant: 'destructive' })
|
||||||
let summary = `Imported ${data.imported ?? 0} presets`
|
return
|
||||||
if ((data.skipped ?? 0) > 0) summary += `, ${data.skipped} duplicates skipped`
|
}
|
||||||
if ((data.foldersCreated ?? 0) > 0) summary += ` — ${data.foldersCreated} folders created`
|
}
|
||||||
toast({ title: 'Import complete', description: `${summary}.` })
|
|
||||||
} catch {
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Import failed',
|
title: 'Copy failed',
|
||||||
description: 'Network error while importing.',
|
description: 'A preset with that name already exists — rename it in "user created" first.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportZip = async () => {
|
||||||
|
if (busy) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const res = await exportPresetsZip(selectedFolder, presets, folders)
|
||||||
|
if (res.ok) {
|
||||||
|
toast({ title: 'Export ready', description: `${plural(res.presets, 'preset')} zipped and downloading.` })
|
||||||
|
} else {
|
||||||
|
toast({ title: 'Export failed', description: res.error, variant: 'destructive' })
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const importZip = async (file: File) => {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
const res = await importPresetsZip(file, selectedFolder)
|
||||||
|
if (!res.ok) {
|
||||||
|
toast({ title: 'Import failed', description: res.error, variant: 'destructive' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applyUserPresets()
|
||||||
|
useAvaStore.getState().toggleFolder(USER_ROOT, true)
|
||||||
|
let summary = `Imported ${res.imported} presets into your browser library`
|
||||||
|
if ((res.skipped ?? 0) > 0) summary += `, ${res.skipped} duplicates skipped`
|
||||||
|
if ((res.foldersCreated ?? 0) > 0) summary += ` — ${res.foldersCreated} folders created`
|
||||||
|
toast({ title: 'Import complete', description: `${summary}.` })
|
||||||
|
} catch {
|
||||||
|
toast({ title: 'Import failed', description: 'Could not read that zip.', variant: 'destructive' })
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
|
|
@ -733,7 +749,7 @@ export function PresetsTab() {
|
||||||
<span className="font-mono tabular-nums text-zinc-400">{presets.length}</span> presets ·{' '}
|
<span className="font-mono tabular-nums text-zinc-400">{presets.length}</span> presets ·{' '}
|
||||||
<span className="font-mono tabular-nums text-zinc-400">{folders.length}</span> folders
|
<span className="font-mono tabular-nums text-zinc-400">{folders.length}</span> folders
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] text-zinc-600">double-click a preset to run it</p>
|
<p className="hidden text-[11px] text-zinc-600 sm:block">double-click a preset to run it</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row">
|
<div className="flex flex-col gap-3 sm:flex-row">
|
||||||
|
|
@ -780,6 +796,14 @@ export function PresetsTab() {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{(folderReadOnly || (selectedPreset && !presetOwn)) && (
|
||||||
|
<span
|
||||||
|
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"
|
||||||
|
title="Read-only — identical for every visitor. Copy it to 'user created' to remix."
|
||||||
|
>
|
||||||
|
<Lock className="size-2.5" aria-hidden /> read-only
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
|
|
@ -793,6 +817,7 @@ export function PresetsTab() {
|
||||||
>
|
>
|
||||||
<Play className="size-3.5" /> Load Preset
|
<Play className="size-3.5" /> Load Preset
|
||||||
</Button>
|
</Button>
|
||||||
|
{presetOwn ? (
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -807,12 +832,59 @@ export function PresetsTab() {
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={openPresetDelete}
|
onClick={openPresetDelete}
|
||||||
title="Delete this preset"
|
title="Delete this preset from your browser library"
|
||||||
className="h-9 gap-1.5 border-zinc-700 hover:border-rose-500/40 hover:text-rose-300"
|
className="h-9 gap-1.5 border-zinc-700 hover:border-rose-500/40 hover:text-rose-300"
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3.5" /> Delete
|
<Trash2 className="size-3.5" /> Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => copyToMyPresets(selectedPreset)}
|
||||||
|
title="Save an editable copy of this preset into your browser's 'user created' folder"
|
||||||
|
className="h-9 w-full gap-1.5 border-zinc-700"
|
||||||
|
>
|
||||||
|
<CopyPlus className="size-3.5" /> Copy to my presets
|
||||||
|
</Button>
|
||||||
|
<p className="text-[11px] leading-relaxed text-zinc-500">
|
||||||
|
Embedded presets are read-only — identical for every visitor. Copy it to make it yours
|
||||||
|
(stored in this browser only).
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : folderReadOnly ? (
|
||||||
|
<>
|
||||||
|
<p className="text-[11px] leading-relaxed text-zinc-500">
|
||||||
|
The embedded library is <span className="text-zinc-300">read-only</span> — the same for every
|
||||||
|
visitor, safe from edits, renames and deletion. Your own presets live under{' '}
|
||||||
|
<span className="font-mono text-zinc-400">user created</span>, stored in this browser.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy || folderPresetCount === 0}
|
||||||
|
onClick={() => void exportZip()}
|
||||||
|
title="Download this folder (with subfolders) as a .zip preserving the tree — built in your browser"
|
||||||
|
className="h-9 gap-1.5 border-zinc-700"
|
||||||
|
>
|
||||||
|
<Download className="size-3.5" /> Export .zip
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => importInputRef.current?.click()}
|
||||||
|
title="Import a .zip of .ava presets — they land in your browser's 'user created' folder"
|
||||||
|
className="h-9 gap-1.5 border-zinc-700"
|
||||||
|
>
|
||||||
|
<Upload className="size-3.5" /> Import .zip
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
@ -820,7 +892,7 @@ export function PresetsTab() {
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => openDialog('createPreset', '')}
|
onClick={() => openDialog('createPreset', '')}
|
||||||
title={`Create a new preset from the starter template inside ${selectedFolder}`}
|
title={`Create a new preset from the starter template inside ${selectedFolder} (this browser)`}
|
||||||
className="h-9 w-full gap-1.5 border-zinc-700"
|
className="h-9 w-full gap-1.5 border-zinc-700"
|
||||||
>
|
>
|
||||||
<FilePlus2 className="size-3.5" /> Create Preset
|
<FilePlus2 className="size-3.5" /> Create Preset
|
||||||
|
|
@ -829,7 +901,7 @@ export function PresetsTab() {
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => openDialog('newFolder', '')}
|
onClick={() => openDialog('newFolder', '')}
|
||||||
title={`Create a subfolder inside ${selectedFolder}`}
|
title={`Create a subfolder inside ${selectedFolder} (this browser)`}
|
||||||
className="h-9 w-full gap-1.5 border-zinc-700"
|
className="h-9 w-full gap-1.5 border-zinc-700"
|
||||||
>
|
>
|
||||||
<FolderPlus className="size-3.5" /> New Subfolder
|
<FolderPlus className="size-3.5" /> New Subfolder
|
||||||
|
|
@ -840,7 +912,7 @@ export function PresetsTab() {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={folderProtected}
|
disabled={folderProtected}
|
||||||
onClick={() => openDialog('rename', folderName)}
|
onClick={() => openDialog('rename', folderName)}
|
||||||
title={folderProtected ? 'Protected folder' : `Rename ${folderName} — everything inside moves with it`}
|
title={folderProtected ? '"user created" is the root of your library' : `Rename ${folderName} — everything inside moves with it`}
|
||||||
className="h-9 gap-1.5 border-zinc-700"
|
className="h-9 gap-1.5 border-zinc-700"
|
||||||
>
|
>
|
||||||
<Pencil className="size-3.5" /> Rename
|
<Pencil className="size-3.5" /> Rename
|
||||||
|
|
@ -850,7 +922,7 @@ export function PresetsTab() {
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled={folderProtected}
|
disabled={folderProtected}
|
||||||
onClick={openFolderDelete}
|
onClick={openFolderDelete}
|
||||||
title={folderProtected ? 'Protected folder' : `Delete ${folderName} and everything inside it`}
|
title={folderProtected ? '"user created" is the root of your library' : `Delete ${folderName} and everything inside it (this browser)`}
|
||||||
className="h-9 gap-1.5 border-zinc-700 hover:border-rose-500/40 hover:text-rose-300"
|
className="h-9 gap-1.5 border-zinc-700 hover:border-rose-500/40 hover:text-rose-300"
|
||||||
>
|
>
|
||||||
<Trash2 className="size-3.5" /> Delete
|
<Trash2 className="size-3.5" /> Delete
|
||||||
|
|
@ -860,13 +932,12 @@ export function PresetsTab() {
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
asChild
|
disabled={busy || folderPresetCount === 0}
|
||||||
title="Download this folder (with subfolders) as a .zip preserving the tree"
|
onClick={() => void exportZip()}
|
||||||
|
title="Download this folder (with subfolders) as a .zip preserving the tree — built in your browser"
|
||||||
className="h-9 gap-1.5 border-zinc-700"
|
className="h-9 gap-1.5 border-zinc-700"
|
||||||
>
|
>
|
||||||
<a href={`/api/presets/export?path=${encodeURIComponent(selectedFolder)}`} download>
|
|
||||||
<Download className="size-3.5" /> Export .zip
|
<Download className="size-3.5" /> Export .zip
|
||||||
</a>
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -932,7 +1003,7 @@ export function PresetsTab() {
|
||||||
open={dialog === 'createPreset'}
|
open={dialog === 'createPreset'}
|
||||||
onOpenChange={(o) => setDialog(o ? 'createPreset' : null)}
|
onOpenChange={(o) => setDialog(o ? 'createPreset' : null)}
|
||||||
title="Create preset"
|
title="Create preset"
|
||||||
description={`New preset from the starter template inside ${selectedFolder}.`}
|
description={`New preset from the starter template inside ${selectedFolder} — saved in this browser.`}
|
||||||
placeholder="Preset name"
|
placeholder="Preset name"
|
||||||
value={nameValue}
|
value={nameValue}
|
||||||
onChange={setNameValue}
|
onChange={setNameValue}
|
||||||
|
|
@ -946,7 +1017,7 @@ export function PresetsTab() {
|
||||||
open={dialog === 'newFolder'}
|
open={dialog === 'newFolder'}
|
||||||
onOpenChange={(o) => setDialog(o ? 'newFolder' : null)}
|
onOpenChange={(o) => setDialog(o ? 'newFolder' : null)}
|
||||||
title="New subfolder"
|
title="New subfolder"
|
||||||
description={`Created inside ${selectedFolder}.`}
|
description={`Created inside ${selectedFolder} — saved in this browser.`}
|
||||||
placeholder="Folder name"
|
placeholder="Folder name"
|
||||||
value={nameValue}
|
value={nameValue}
|
||||||
onChange={setNameValue}
|
onChange={setNameValue}
|
||||||
|
|
@ -987,9 +1058,9 @@ export function PresetsTab() {
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
{deleteTarget?.kind === 'folder'
|
{deleteTarget?.kind === 'folder'
|
||||||
? deleteTarget.presetCount > 0 || deleteTarget.folderCount > 0
|
? deleteTarget.presetCount > 0 || deleteTarget.folderCount > 0
|
||||||
? `Delete "${deleteTarget.name}" and everything inside it? This removes ${plural(deleteTarget.presetCount, 'preset')}${deleteTarget.folderCount > 0 ? ` in ${plural(deleteTarget.folderCount, 'folder')}` : ''}. This cannot be undone.`
|
? `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.`
|
||||||
: `Delete "${deleteTarget.name}"? The folder is empty. This cannot be undone.`
|
: `Delete "${deleteTarget.name}"? The folder is empty. This cannot be undone.`
|
||||||
: `Delete preset "${deleteTarget?.name ?? ''}"? This cannot be undone.`}
|
: `Delete preset "${deleteTarget?.name ?? ''}" from this browser? This cannot be undone.`}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,18 @@ import {
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { STARTER_TEMPLATE } from '@/lib/ava/presets'
|
import { STARTER_TEMPLATE } from '@/lib/ava/presets'
|
||||||
import { validateScript } from '@/lib/ava/script'
|
import { validateScript } from '@/lib/ava/script'
|
||||||
import { refreshPresets, useAvaStore, persistDefaultScript, type PresetItem } from '@/lib/store'
|
import {
|
||||||
|
applyUserPresets,
|
||||||
|
persistDefaultScript,
|
||||||
|
useAvaStore,
|
||||||
|
} from '@/lib/store'
|
||||||
import { applyScriptText } from '@/components/ava/audio-source'
|
import { applyScriptText } from '@/components/ava/audio-source'
|
||||||
import { HelpPanel } from '@/components/ava/help-panel'
|
import { HelpPanel } from '@/components/ava/help-panel'
|
||||||
import { sanitizeFileName } from '@/components/ava/shared'
|
import { sanitizeFileName } from '@/components/ava/shared'
|
||||||
import { useToast } from '@/hooks/use-toast'
|
import { useToast } from '@/hooks/use-toast'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { USER_ROOT, isUserAreaPath } from '@/lib/preset-paths'
|
||||||
|
import { createUserPreset, presetIsLocal, updateUserPreset } from '@/lib/ava/user-presets'
|
||||||
|
|
||||||
/** Middle-truncated save-target path for the chip: "root/embedded/…/Name". */
|
/** Middle-truncated save-target path for the chip: "root/embedded/…/Name". */
|
||||||
function formatSavePath(folderPath: string, name: string): string {
|
function formatSavePath(folderPath: string, name: string): string {
|
||||||
|
|
@ -106,29 +112,30 @@ export function ScriptTab() {
|
||||||
/** Overwrite the target preset (picked in the Presets tab) with the editor script. */
|
/** Overwrite the target preset (picked in the Presets tab) with the editor script. */
|
||||||
const saveToTarget = async () => {
|
const saveToTarget = async () => {
|
||||||
if (!editTarget || saving) return
|
if (!editTarget || saving) return
|
||||||
setSaving(true)
|
// v6.2: the embedded library is read-only — only the visitor's own
|
||||||
try {
|
// browser presets ("local-…" ids) can be overwritten.
|
||||||
const v = validateScript(scriptText)
|
if (!presetIsLocal(editTarget.id)) {
|
||||||
const body: Record<string, string> = { content: scriptText }
|
|
||||||
if (v.meta?.author) body.author = v.meta.author
|
|
||||||
const res = await fetch(`/api/presets/${editTarget.id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
})
|
|
||||||
const data = (await res.json().catch(() => null)) as { preset?: PresetItem; error?: string } | null
|
|
||||||
if (!res.ok) {
|
|
||||||
toast({
|
toast({
|
||||||
title: 'Save failed',
|
title: 'Embedded presets are read-only',
|
||||||
description: data?.error ?? `Server responded ${res.status}.`,
|
description:
|
||||||
|
'Use "Save As…" — it stores your copy under "user created" in this browser, leaving the shared library untouched.',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await refreshPresets()
|
setSaving(true)
|
||||||
toast({ title: `Saved to "${editTarget.name}"`, description: 'The preset now holds the current editor script.' })
|
try {
|
||||||
} catch {
|
const v = validateScript(scriptText)
|
||||||
toast({ title: 'Save failed', description: 'Network error while saving the preset.', variant: 'destructive' })
|
const res = updateUserPreset(editTarget.id, {
|
||||||
|
content: scriptText,
|
||||||
|
author: v.meta?.author || undefined,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
toast({ title: 'Save failed', description: res.error, variant: 'destructive' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applyUserPresets()
|
||||||
|
toast({ title: `Saved to "${editTarget.name}"`, description: 'The preset now holds the current editor script (stored in this browser).' })
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
|
|
@ -141,7 +148,7 @@ export function ScriptTab() {
|
||||||
setSaveAsOpen(true)
|
setSaveAsOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save a copy of the editor script under a new name in the same folder. */
|
/** Save a copy of the editor script under a new name (always in the user area). */
|
||||||
const saveAsCopy = async () => {
|
const saveAsCopy = async () => {
|
||||||
if (!editTarget) return
|
if (!editTarget) return
|
||||||
const trimmed = saveAsName.trim()
|
const trimmed = saveAsName.trim()
|
||||||
|
|
@ -152,34 +159,26 @@ export function ScriptTab() {
|
||||||
setSaveAsBusy(true)
|
setSaveAsBusy(true)
|
||||||
try {
|
try {
|
||||||
const v = validateScript(scriptText)
|
const v = validateScript(scriptText)
|
||||||
const res = await fetch('/api/presets', {
|
// embedded / root targets redirect into "user created" — copies are
|
||||||
method: 'POST',
|
// always the visitor's own, stored in this browser
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const folderPath = isUserAreaPath(editTarget.folderPath) ? editTarget.folderPath : USER_ROOT
|
||||||
body: JSON.stringify({
|
const res = createUserPreset({
|
||||||
name: trimmed,
|
name: trimmed,
|
||||||
content: scriptText,
|
content: scriptText,
|
||||||
folderPath: editTarget.folderPath,
|
folderPath,
|
||||||
author: v.meta?.author || 'you',
|
author: v.meta?.author || 'you',
|
||||||
}),
|
|
||||||
})
|
|
||||||
const data = (await res.json().catch(() => null)) as { preset?: PresetItem; error?: string } | null
|
|
||||||
if (!res.ok || !data?.preset) {
|
|
||||||
toast({
|
|
||||||
title: 'Save failed',
|
|
||||||
description: data?.error ?? `Server responded ${res.status}.`,
|
|
||||||
variant: 'destructive',
|
|
||||||
})
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
setSaveAsError(res.error)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await refreshPresets()
|
applyUserPresets()
|
||||||
setEditTarget({ id: data.preset.id, name: data.preset.name, folderPath: data.preset.folderPath })
|
setEditTarget({ id: res.preset.id, name: res.preset.name, folderPath: res.preset.folderPath })
|
||||||
setSaveAsOpen(false)
|
setSaveAsOpen(false)
|
||||||
toast({
|
toast({
|
||||||
title: 'Copy saved',
|
title: 'Copy saved',
|
||||||
description: `"${data.preset.name}" created in ${data.preset.folderPath}.`,
|
description: `"${res.preset.name}" created in ${res.preset.folderPath} (this browser).`,
|
||||||
})
|
})
|
||||||
} catch {
|
|
||||||
toast({ title: 'Save failed', description: 'Network error while saving the copy.', variant: 'destructive' })
|
|
||||||
} finally {
|
} finally {
|
||||||
setSaveAsBusy(false)
|
setSaveAsBusy(false)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
// (v6.0 = rename to Open Visualization Engine + version indicator).
|
// (v6.0 = rename to Open Visualization Engine + version indicator).
|
||||||
//
|
//
|
||||||
// History:
|
// History:
|
||||||
|
// 6.2 — security hardening: read-only embedded library (server refuses all
|
||||||
|
// writes) + per-visitor "user created" presets stored in the browser
|
||||||
// 6.1 — preset library quality pass: every embedded stand-in is now a UNIQUE
|
// 6.1 — preset library quality pass: every embedded stand-in is now a UNIQUE
|
||||||
// generated visual (16 families × seeded parameters), approx badge
|
// generated visual (16 families × seeded parameters), approx badge
|
||||||
// 6.0 — renamed to "Open Visualization Engine" + footer version stamp
|
// 6.0 — renamed to "Open Visualization Engine" + footer version stamp
|
||||||
|
|
@ -15,4 +17,4 @@
|
||||||
|
|
||||||
export const APP_NAME = 'Open Visualization Engine'
|
export const APP_NAME = 'Open Visualization Engine'
|
||||||
export const APP_SHORT = 'OVE'
|
export const APP_SHORT = 'OVE'
|
||||||
export const APP_VERSION = '6.1'
|
export const APP_VERSION = '6.2'
|
||||||
|
|
|
||||||
BIN
tool-results/e2e-v62/01-desktop-presets.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
tool-results/e2e-v62/02-embedded-folder-selected.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
tool-results/e2e-v62/03-embedded-preset-selected.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
tool-results/e2e-v62/04-script-tab-created.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
tool-results/e2e-v62/05-save-blocked-toast.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
tool-results/e2e-v62/06-user-library-empty.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
tool-results/e2e-v62/07-mobile-firstpaint.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
tool-results/e2e-v62/08-mobile-bottom.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
tool-results/e2e-v62/09-mobile-embedded-selected.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
834
tool-results/read_1793086254569_65b3b8ecd40e.txt
Normal file
|
|
@ -0,0 +1,834 @@
|
||||||
|
1→# Worklog — AVA (Advanced Visualization Agent)
|
||||||
|
2→
|
||||||
|
3→Project: Audio-reactive visualization agent (AVS-style scripting, 3-band spectrum detection,
|
||||||
|
4→predictive beat tracking). Built as a Next.js 16 web app (reference implementation) plus a
|
||||||
|
5→Rust/Vulkan scaffold for the future native cross-platform export.
|
||||||
|
6→
|
||||||
|
7→User requirements:
|
||||||
|
8→- 3-band detection: Bass 20–100 Hz, Mid 700 Hz–2 kHz, High 7 kHz–16 kHz (user-adjustable)
|
||||||
|
9→- Beat detection from bass + beat PREDICTION (keeps beat when bass drops out)
|
||||||
|
10→- Green dot beat indicator + "Set Beat" tap-tempo button
|
||||||
|
11→- AVS-style custom visualization script editor: edit, save as default, save to file, load from file
|
||||||
|
12→- Help section with variables/functions reference
|
||||||
|
13→- 5 preset scripts (user provided real Winamp AVS presets on Google Drive)
|
||||||
|
14→- Rust + Vulkan target for Linux/Windows/macOS export (scaffold + blueprint delivered here)
|
||||||
|
15→
|
||||||
|
16→---
|
||||||
|
17→Task ID: 1
|
||||||
|
18→Agent: main (Z.ai Code)
|
||||||
|
19→Task: Project inspection + Google Drive preset retrieval
|
||||||
|
20→
|
||||||
|
21→Work Log:
|
||||||
|
22→- Verified dev server running on port 3000 (Next.js 16.1.3, dev.log clean)
|
||||||
|
23→- Fetched Google Drive folder (public) — extracted 5 file IDs and downloaded all files:
|
||||||
|
24→ - "Synth-C - Gap The Mind.avs" (12.5 KB)
|
||||||
|
25→ - "zamuz - wonder 'bout.avs" (34 KB)
|
||||||
|
26→ - "Synth-C - Quantization.avs" (14.8 KB)
|
||||||
|
27→ - "zamuz - keep tryin'.avs" (19 KB)
|
||||||
|
28→ - "zamuz - vorticity (brain activity remix).avs" (17 KB)
|
||||||
|
29→- Parsed binary AVS format (null-terminated strings): extracted Superscope code, Texer II
|
||||||
|
30→ configs, movement expressions. These are scene classics from visbot.net.
|
||||||
|
31→- Decided engine design: AVS-eel-flavored expression language (if/above/below/equal/rand,
|
||||||
|
32→ getspec/getosc-equivalents sv/v, reg-free flat variables) so the presets port naturally.
|
||||||
|
33→
|
||||||
|
34→Stage Summary:
|
||||||
|
35→- All 5 original presets secured and understood; will be adapted to the AVA script dialect
|
||||||
|
36→ (originals preserved as downloadable files in public/avs-originals/).
|
||||||
|
37→- Core engine architecture defined: expression compiler, script parser, BeatTracker (PLL-style
|
||||||
|
38→ predictor + tap tempo), AudioEngine (WebAudio FFT, mic/file/demo synth), canvas renderer.
|
||||||
|
39→
|
||||||
|
40→---
|
||||||
|
41→Task ID: 2
|
||||||
|
42→Agent: main (Z.ai Code)
|
||||||
|
43→Task: Project setup — deps, Prisma schema, DB, original preset files
|
||||||
|
44→
|
||||||
|
45→Work Log:
|
||||||
|
46→- Installed jszip (for the Rust scaffold zip download API)
|
||||||
|
47→- Prisma schema rewritten: `Preset` (name/author/description/content/isBuiltIn, unique name)
|
||||||
|
48→ and `Settings` (defaultScript + bandConfig + beatConfig JSON) — ran `bun run db:push` OK
|
||||||
|
49→- Copied the 5 original Winamp .avs binaries to public/avs-originals/ (downloadable from UI)
|
||||||
|
50→
|
||||||
|
51→Stage Summary:
|
||||||
|
52→- DB ready; jszip available; originals served at /avs-originals/*.avs
|
||||||
|
53→
|
||||||
|
54→---
|
||||||
|
55→Task ID: 3
|
||||||
|
56→Agent: main (Z.ai Code)
|
||||||
|
57→Task: AVA core engine libraries (src/lib/ava/*)
|
||||||
|
58→
|
||||||
|
59→Work Log:
|
||||||
|
60→- types.ts — BandConfig / BeatConfig / BandLevels / BeatFrame + defaults
|
||||||
|
61→ (bass 20–100 Hz, mid 700–2000 Hz, high 7000–16000 Hz as requested)
|
||||||
|
62→- expression.ts — full expression compiler: tokenizer → recursive-descent parser → closure
|
||||||
|
63→ tree. AVS-eel flavored: if() is LAZY, && / || short-circuit, ternary, ^ power (right-assoc),
|
||||||
|
64→ assignment valid inside call args + ternary branches (if(cond, x=1, x=2) works),
|
||||||
|
65→ newline & ';' statement separators (newlines inside parens ignored), '#'/'//' comments,
|
||||||
|
66→ deterministic hash rand(seed). 30+ functions incl. above/below/equal/band/bor/bnot/frac.
|
||||||
|
67→- script.ts — text format parser: header keys (name/author/description/mode/blend/persist/
|
||||||
|
68→ linewidth) + [init]/[frame]/[beat]/[point] sections, error objects with section+line,
|
||||||
|
69→ validateScript() never throws.
|
||||||
|
70→- beat.ts — BeatTracker: rolling-average energy detection w/ threshold+minInterval+floor,
|
||||||
|
71→ interval learning (median + EMA period lock, spread gating), PLL-style phase predictor that
|
||||||
|
72→ keeps firing beats when bass drops (confidence-gated, slowly decays), tap() tempo learning.
|
||||||
|
73→- audio.ts — AudioEngine: WebAudio AnalyserNode FFT 8192 (~5.4 Hz bins), byte-spectrum band
|
||||||
|
74→ averaging with attack 35ms / release 220ms smoothing; sources: demo (synthesized techno:
|
||||||
|
75→ kick 160→43Hz sweep, snare bandpass 1.8k, hats HP 8.2k, saw bassline — audible + analyzable),
|
||||||
|
76→ mic (never routed to speakers), file (loops, audible). getWave/getSpectrum for scripts.
|
||||||
|
77→- presets.ts — 5 built-in presets ADAPTED from the user's real AVS files:
|
||||||
|
78→ Gap The Mind (radial warp rings), wonder 'bout (bass-spawned expanding rings),
|
||||||
|
79→ Quantization (grid-snapped dot rings), keep tryin' (bass burst dots + swirl),
|
||||||
|
80→ vorticity (twisting spiral arms) + STARTER_TEMPLATE.
|
||||||
|
81→- engine.ts — AvaEngine: rAF loop (bands → beat → env → frame/beat progs → point prog ×N →
|
||||||
|
82→ draw), canvas 2d renderer with additive blending, per-point color, 2-pass glow,
|
||||||
|
83→ persistence trails, lines/dots/solid modes, DPR-aware resize via ResizeObserver,
|
||||||
|
84→ throttled stats callback for the UI. Singleton via getEngine().
|
||||||
|
85→- SMOKE TESTED via bun script: all 5 presets parse & execute 120 frames; lazy-if/ternary/pow
|
||||||
|
86→ OK; beat sim locks 124.1 BPM from 124 BPM kick train; predictor sustains beats through 10s
|
||||||
|
87→ of silence; tap tempo locks 120 BPM from 500ms taps. (2 parser bugs found & fixed: assignment
|
||||||
|
88→ inside call args; newline statement separators with paren-awareness.)
|
||||||
|
89→
|
||||||
|
90→Stage Summary:
|
||||||
|
91→- Core engine complete and verified. Env variables exposed to scripts: n, t, time, dt, frame,
|
||||||
|
92→ bass/mid/treb (+ _att), vol (+ _att), beat, bpm, beatphase, beatconf, beats, w, h, aspect,
|
||||||
|
93→ persist, i, v, sv, x, y, red, green, blue, skip, size, alpha.
|
||||||
|
94→
|
||||||
|
95→---
|
||||||
|
96→Task ID: 4
|
||||||
|
97→Agent: main (Z.ai Code)
|
||||||
|
98→Task: API routes
|
||||||
|
99→
|
||||||
|
100→Work Log:
|
||||||
|
101→- GET/POST /api/presets — GET seeds built-ins on first run and lists all; POST upserts user
|
||||||
|
102→ presets by name (built-ins protected, 409 on name clash with built-in)
|
||||||
|
103→- PUT/DELETE /api/presets/[id] — user presets only
|
||||||
|
104→- GET/PUT /api/settings — defaultScript + bandConfig + beatConfig (upsert, JSON strings in DB)
|
||||||
|
105→- Created src/lib/ava/blueprint.ts STUB (interface Blueprint + placeholder) for Task 3-b
|
||||||
|
106→
|
||||||
|
107→Stage Summary:
|
||||||
|
108→- Backend complete. Frontend contract: GET /api/presets → { presets: [...] };
|
||||||
|
109→ POST /api/presets {name, content, author?, description?} → {preset};
|
||||||
|
110→ GET /api/settings → { defaultScript, bandConfig, beatConfig };
|
||||||
|
111→ PUT /api/settings { defaultScript? , bandConfig?, beatConfig? }.
|
||||||
|
112→
|
||||||
|
113→
|
||||||
|
114→---
|
||||||
|
115→Task ID: 3-a
|
||||||
|
116→Agent: full-stack-developer (UI)
|
||||||
|
117→Task: Complete UI for AVA — single-page app (dark club/VJ aesthetic) with header
|
||||||
|
118→(audio source controls, BPM readout, green beat dot + Set Beat pairing), visual
|
||||||
|
119→stage (canvas + engine loop), 5-tab control panel (Presets / Script / Detect /
|
||||||
|
120→Beat / Native) and sticky footer status bar.
|
||||||
|
121→
|
||||||
|
122→Work Log:
|
||||||
|
123→- Read worklog + all engine APIs (types/engine/script/presets/blueprint/audio/beat)
|
||||||
|
124→ and the existing API routes before writing any code.
|
||||||
|
125→- src/lib/store.ts — zustand store: throttled stats (AvaStats = EngineStats +
|
||||||
|
126→ beatCount), beatFlash pulses, bandConfig/beatConfig mirrors that write the
|
||||||
|
127→ engine live + persist (localStorage instant, PUT /api/settings debounced 600 ms
|
||||||
|
128→ with shape guards), preset list + refreshPresets(), script text, active tab.
|
||||||
|
129→- src/components/ava/audio-source.ts — tapBeat() (engine.beat.tap + dot flash +
|
||||||
|
130→ lastTap), applyScriptText() (validate → setScript, engine.setScript), and
|
||||||
|
131→ useAudioSource() hook wrapping startDemo/startMic/startFile/stop with error
|
||||||
|
132→ mapping (mic permission / no device / decode failure → destructive toast) and
|
||||||
|
133→ store sync from engine.audio.kind (authoritative).
|
||||||
|
134→- src/components/ava/beat-dot.tsx — the green indicator: SVG phase ring
|
||||||
|
135→ (stroke-dashoffset follows beat.phase, 150 ms linear transition), emerald dot
|
||||||
|
136→ that scales 1.9× + glows 180 ms on audio/tap beats, and for predicted beats an
|
||||||
|
137→ expanding outline ring (framer-motion, 450 ms) + "PRED" badge. Flash state is
|
||||||
|
138→ derived (seq vs clearedSeq) — no setState-in-effect.
|
||||||
|
139→- src/app/page.tsx — useInitialLoad (presets + settings; server → localStorage →
|
||||||
|
140→ first preset → STARTER_TEMPLATE fallback chain; applies configs + script),
|
||||||
|
141→ useBeatWatcher (see decisions below), useSpaceToTap (Space outside
|
||||||
|
142→ inputs/textarea/select/button → tap, preventDefault). Root layout:
|
||||||
|
143→ min-h-screen flex-col, lg:h-screen with internal panel scroll (footer always
|
||||||
|
144→ at viewport bottom; mobile pushes footer naturally).
|
||||||
|
145→- src/components/ava/header.tsx — sticky, backdrop-blur; emerald logo square +
|
||||||
|
146→ AVA title + subtitle (hidden on mobile); segmented Demo/Mic/File/Stop control
|
||||||
|
147→ (44 px targets, active state emerald, hidden file input); demo BPM slider
|
||||||
|
148→ (60–200, only while demo active); right cluster = BPM readout (mono, emerald,
|
||||||
|
149→ '—' when unknown) + BeatDot + Set Beat button, always visible, wraps to row 1
|
||||||
|
150→ on mobile (source controls wrap to row 2).
|
||||||
|
151→- src/components/ava/visual-stage.tsx — owns engine lifecycle (engine.start/
|
||||||
|
152→ stop + onStats → store), idle glass overlay with 3 big source buttons
|
||||||
|
153→ (AnimatePresence fade), top-left FPS/PTS readout (pointer-events-none), top-
|
||||||
|
154→ right fullscreen toggle (requestFullscreen on container). Canvas absolute
|
||||||
|
155→ fill; engine's ResizeObserver handles DPR.
|
||||||
|
156→- src/components/ava/presets-tab.tsx — preset cards (name/author/description
|
||||||
|
157→ line-clamp-3, mode + built-in/custom badges), Load (applies + toasts, jumps
|
||||||
|
158→ to Script tab on compile error), ".avs original" download link for built-ins
|
||||||
|
159→ via originalFileUrl(name), two-step Delete (3 s confirm timeout) for customs,
|
||||||
|
160→ "New Script" (STARTER_TEMPLATE + switch tab), save-as-preset section (POST,
|
||||||
|
161→ 409 handled, refresh + toast). List: max-h-96 + thin styled scrollbar.
|
||||||
|
162→- src/components/ava/script-tab.tsx — toolbar (Apply ▶ / Save as Default 💾 /
|
||||||
|
163→ Save to File ⬇ Blob download <meta.name>.ava / Load from File 📂 (.ava,.avs,
|
||||||
|
164→ .txt, auto-applies when valid) / New / Help ?), gutter+textarea editor
|
||||||
|
165→ (synced scroll, Tab → 2 spaces, min-h-320, wrap=off), rose gutter line on the
|
||||||
|
166→ error line, live debounced validation (250 ms), status line "✔ Compiled OK —
|
||||||
|
167→ mode: lines, n=480" (n from running [init] on a scratch env) / compile error
|
||||||
|
168→ / stats.error runtime errors.
|
||||||
|
169→- src/components/ava/help-panel.tsx — Quick Start, full variables table (frame
|
||||||
|
170→ + point-only), functions table, operators, 5 example formulas, .ava file
|
||||||
|
171→ format doc.
|
||||||
|
172→- src/components/ava/detect-tab.tsx — Bass/Mid/High cards: Low/High Hz sliders
|
||||||
|
173→ (bass 10–300/20–500, mid 100–5000/300–8000, high 1000–12000/4000–20000; low <
|
||||||
|
174→ high enforced with min gaps 10/50/200 in the store), Gain 0.1–4, mono Hz
|
||||||
|
175→ readouts, live VU meters (instant fill emerald/amber/rose + _att peak-hold
|
||||||
|
176→ tick), per-band reset.
|
||||||
|
177→- src/components/ava/beat-tab.tsx — 80 px BeatDot + source badge (AUDIO/PRED/
|
||||||
|
178→ TAP), big BPM readout, large SET BEAT button (framer-motion whileTap), tap
|
||||||
|
179→ feedback ("Tap 3… keep tapping" → "Locked: 128.0 BPM"), confidence bar, beat
|
||||||
|
180→ count, sensitivity (threshold 1.1–2.5, "lower = more sensitive"), min
|
||||||
|
181→ interval 150–600 ms, prediction switch ("Keeps the beat alive when the bass
|
||||||
|
182→ drops out"), how-it-works explainer.
|
||||||
|
183→- src/components/ava/native-tab.tsx — renders BLUEPRINT (title/intro/sections/
|
||||||
|
184→ bullets/code blocks in scrollable pre), emerald "Download Rust Scaffold
|
||||||
|
185→ (.zip)" <a href="/api/rust-scaffold" download>, identical-language note.
|
||||||
|
186→- src/components/ava/footer-bar.tsx — sticky mt-auto status bar: SRC/FPS/PTS/
|
||||||
|
187→ BPM/SCRIPT micro-stats (mono), green "engine ok" / pulsing red "script error".
|
||||||
|
188→- src/app/layout.tsx — dark class on <html>, title "AVA — Advanced
|
||||||
|
189→ Visualization Agent", body bg-zinc-950.
|
||||||
|
190→- FIXED BUGS during browser testing: (1) Detect tab crashed — levels key for
|
||||||
|
191→ the high band is `treb` not `high` (added levelKey/attKey mapping to BandSpec);
|
||||||
|
192→ (2) lint: setState-in-effect in BeatDot (derived flash state instead);
|
||||||
|
193→ (3) lint: // text node in help panel.
|
||||||
|
194→- Tuned src/hooks/use-toast.ts: TOAST_LIMIT 1→3, TOAST_REMOVE_DELAY 100 s→5 s
|
||||||
|
195→ (lingering toasts were covering header/panel buttons during testing).
|
||||||
|
196→- VERIFIED in headless browser: initial load seeds presets + applies Gap The
|
||||||
|
197→ Mind (480 pts @60 fps); demo start/stop + DEMO slider; beat dot flashing live
|
||||||
|
198→ (scale 1↔1.9); tap tempo lock ("Locked: 119.9 BPM" from 4×500 ms taps, Space
|
||||||
|
199→ key too); after Stop the PRED badge/outline ring shows prediction carrying
|
||||||
|
200→ the beat; preset load/save/delete (two-step) round-trip via API; Save as
|
||||||
|
201→ Default → PUT /api/settings + localStorage; band slider → debounced PUT;
|
||||||
|
202→ .avs originals serve 200; script upload (auto-apply, n=300) + export to
|
||||||
|
203→ Upload Test.ava; mic error toast ("No microphone found" in this headless
|
||||||
|
204→ env); no blue/indigo anywhere; lint clean; tsc clean for owned files.
|
||||||
|
205→
|
||||||
|
206→Stage Summary:
|
||||||
|
207→- Files created: src/lib/store.ts; src/components/ava/{shared.ts, audio-source.ts,
|
||||||
|
208→ beat-dot.tsx, header.tsx, visual-stage.tsx, presets-tab.tsx, script-tab.tsx,
|
||||||
|
209→ help-panel.tsx, detect-tab.tsx, beat-tab.tsx, native-tab.tsx, footer-bar.tsx,
|
||||||
|
210→ control-panel.tsx}. Modified: src/app/page.tsx (replaced), src/app/layout.tsx,
|
||||||
|
211→ src/hooks/use-toast.ts (toast duration/limit only).
|
||||||
|
212→- KEY DECISION — beat flash sampling: engine stats are throttled (~8 Hz) but
|
||||||
|
213→ beat=true lasts one frame, so most beats are invisible in stats. The watcher
|
||||||
|
214→ in page.tsx ALSO fires on beat.phase running backwards (real beats reset
|
||||||
|
215→ phase to 0; the predictor grid wraps at 1). Classification: stats.beat.beat
|
||||||
|
216→ sampled → exact source; phase drop + prediction on + confidence > 0.35 +
|
||||||
|
217→ bassAtt < 0.06 → 'predicted'; phase drop + bassAtt > 0.05 → 'audio'; silent
|
||||||
|
218→ no-lock → no flash (idle dot doesn't pulse). Tap flashes suppressed for
|
||||||
|
219→ 300 ms to avoid double pulses. Verified working in-browser.
|
||||||
|
220→- KEY DECISION — desktop uses lg:h-screen + internal panel scroll so the footer
|
||||||
|
221→ stays at the viewport bottom and the canvas fills the height; mobile uses
|
||||||
|
222→ natural flow (min-h-screen + mt-auto footer) so the footer is pushed down.
|
||||||
|
223→- Store is the only place configs touch the engine (applyBandConfig/
|
||||||
|
224→ setBandValue etc.), keeping live-update + persistence consistent everywhere.
|
||||||
|
225→- NOTE for Task 5 (integration): /api/rust-scaffold is linked from the Native
|
||||||
|
226→ tab but the route does not exist yet (Task 3-b owns it — expect 404 until
|
||||||
|
227→ then). BLUEPRINT is still the stub; the Native tab renders whatever 3-b puts
|
||||||
|
228→ in it, no UI changes needed. Settings row 'main' currently holds
|
||||||
|
229→ defaultScript="Gap The Mind" + default bandConfig from testing (both equal
|
||||||
|
230→ defaults — safe to leave or clear). Pre-existing tsc errors in
|
||||||
|
231→ src/lib/ava/{audio,script}.ts and examples/ are NOT from this task
|
||||||
|
232→ (ignoreBuildErrors is set).
|
||||||
|
233→
|
||||||
|
234→---
|
||||||
|
235→Task ID: 3-b
|
||||||
|
236→Agent: general-purpose (Rust scaffold + blueprint) — [timed out at the very end;
|
||||||
|
237→ deliverables verified complete by the main agent, which appends this entry]
|
||||||
|
238→
|
||||||
|
239→Task: Rust/Vulkan native-export scaffold + architecture blueprint + zip download API
|
||||||
|
240→
|
||||||
|
241→Work Log:
|
||||||
|
242→- Built rust-agent/ cargo crate (ava-agent, edition 2021): winit 0.30 (ApplicationHandler
|
||||||
|
243→ API), wgpu 22 (Vulkan-first; `vulkan-force` feature for MoltenVK on macOS), cpal 0.15,
|
||||||
|
244→ rustfft 6.2, pollster, anyhow
|
||||||
|
245→- src/audio.rs — cpal default-input capture, ring buffer, AudioProcessor::poll() per frame
|
||||||
|
246→- src/spectrum.rs — Hann-window 8192 FFT, band levels w/ same attack(35ms)/release(220ms)
|
||||||
|
247→ smoothing as the web engine
|
||||||
|
248→- src/beat.rs — 1:1 port of beat.ts (same constants, median+EMA lock, PLL predictor, taps)
|
||||||
|
249→- src/script/{lexer,parser,env,mod}.rs — expression engine + .ava format parser with the
|
||||||
|
250→ same lazy-if / assignment-in-args / deterministic-rand semantics
|
||||||
|
251→- src/engine.rs — script runtime host (init/frame/beat/point stepping, shared state)
|
||||||
|
252→- src/render/{mod.rs,shader.wgsl} — wgpu pipelines: audio-reactive fullscreen background +
|
||||||
|
253→ additive point cloud, #[repr(C)] uniforms, persistence via TODO'd feedback texture
|
||||||
|
254→- presets/*.ava — all six scripts (5 classics + starter) portable across engines
|
||||||
|
255→- Rewrote src/lib/ava/blueprint.ts with 8 real sections (architecture map, wgpu/Vulkan
|
||||||
|
256→ strategy, audio pipeline, beat engine, script engine, rendering, per-platform builds,
|
||||||
|
257→ roadmap)
|
||||||
|
258→- Built src/app/api/rust-scaffold/route.ts (jszip walk, skip target/.git/node_modules)
|
||||||
|
259→
|
||||||
|
260→Stage Summary:
|
||||||
|
261→- Scaffold ~1,700 lines, all files present; zip route verified 200 in dev.log; lint clean.
|
||||||
|
262→- NOT compiled (no cargo in sandbox) — scaffold quality, clearly TODO-marked where partial.
|
||||||
|
263→
|
||||||
|
264→---
|
||||||
|
265→Task ID: 5
|
||||||
|
266→Agent: main (Z.ai Code)
|
||||||
|
267→Task: Integration, lint, agent-browser E2E verification & fixes
|
||||||
|
268→
|
||||||
|
269→Work Log:
|
||||||
|
270→- bun run lint → zero errors across the whole project
|
||||||
|
271→- dev.log clean: / 200, /api/presets 200, /api/settings 200, /api/rust-scaffold 200,
|
||||||
|
272→ no runtime errors, no hydration warnings
|
||||||
|
273→- Agent-browser E2E golden path, all VERIFIED:
|
||||||
|
274→ * initial load: idle overlay, 5 preset cards + .avs original links, tabs render
|
||||||
|
275→ * Demo Track → canvas lit (30.7% pixels @60fps, 480 pts), footer SRC/FPS/PTS/BPM live
|
||||||
|
276→ * footer shows detected 120.0 BPM from the demo kick pattern
|
||||||
|
277→ * Set Beat ×4 taps → "Locked: 99.8 BPM" (matches real ~600ms tap intervals), confidence 65%
|
||||||
|
278→ * Beat tab: AUDIO badge, beat count, sensitivity ×1.40, min interval 250ms, prediction switch
|
||||||
|
279→ * Stop audio → badge switches to PRED, beat count 20→35 (predictor keeps the beat),
|
||||||
|
280→ confidence decays 65%→35% — core requirement verified
|
||||||
|
281→ * Script editor: broken script → "✘ [point] line 8: Unknown function 'badfunc'";
|
||||||
|
282→ fixed script → "✔ Compiled OK — mode: lines, n=300"
|
||||||
|
283→ * Help panel: variables table, functions, operators, examples
|
||||||
|
284→ * Save as preset → "Smoke Test Ring" listed + toast; two-step delete verified
|
||||||
|
285→ * Save as Default → PUT /api/settings verified via curl; reload auto-loads the default
|
||||||
|
286→ * Detect tab: Bass 20–100 Hz / Mid 700–2000 Hz / High 7k–16 kHz cards, gains, live
|
||||||
|
287→ meters reading real audio (0.78/0.60/0.32)
|
||||||
|
288→ * Native tab: blueprint sections render + zip download (116 KB, 25 files, valid zip)
|
||||||
|
289→ * Mobile 390×844: responsive, footer pushed naturally, sticks to viewport bottom when scrolled
|
||||||
|
290→- Cleanup: deleted the test preset, cleared test defaultScript (DB + localStorage) so a
|
||||||
|
291→ fresh visit loads "Gap The Mind"
|
||||||
|
292→
|
||||||
|
293→Stage Summary:
|
||||||
|
294→- ALL acceptance criteria verified in-browser. Zero console errors. The application is
|
||||||
|
295→ fully interactive: 3-band detection, beat detection + prediction + tap learning, AVS-style
|
||||||
|
296→ scripting with editor/help/save/load, 5 authentic presets (adapted), Rust scaffold export.
|
||||||
|
297→
|
||||||
|
298→---
|
||||||
|
299→Task ID: 6
|
||||||
|
300→Agent: general-purpose
|
||||||
|
301→Task: "Stream a link" backend — yt-dlp link resolution + same-origin audio byte proxy (POST /api/resolve, GET /api/audio/stream)
|
||||||
|
302→
|
||||||
|
303→Work Log:
|
||||||
|
304→- Read worklog (Tasks 1–5) and existing routes; confirmed dev server already running on :3000
|
||||||
|
305→ (Node, next-server v16.1.3) — never restarted it; no build, no new deps, no git
|
||||||
|
306→- Installed yt-dlp standalone binary: bin/yt-dlp (GitHub latest yt-dlp_linux), chmod +x,
|
||||||
|
307→ --version prints 2026.10.21
|
||||||
|
308→- src/lib/server/ssrf.ts — assertPublicHttpUrl(raw): http(s) only, explicit port must be 80/443,
|
||||||
|
309→ localhost/.localhost/.local/.internal banned, IPv4 privates (0/8, 10/8, 100.64/10, 127/8,
|
||||||
|
310→ 169.254/16, 172.16/12, 192.168/16), IPv6 ::/::1/fc00::/7/fe80::/10 plus IPv4-mapped
|
||||||
|
311→ ::ffff:a.b.c.d (embedded v4 checked; hand-written IPv6 expander handles v4 tails + zone ids),
|
||||||
|
312→ then dns.promises.lookup(all:true) with EVERY result re-checked by the same rules (kills
|
||||||
|
313→ nip.io / DNS-rebind tricks). Throws user-safe messages; returns the parsed URL.
|
||||||
|
314→- src/lib/server/stream-store.ts — Map on globalThis.__avaStreams via a typed interface with an
|
||||||
|
315→ optional property + `??=` (no `any`): token = randomBytes(16).hex (32 chars), entries
|
||||||
|
316→ { remoteUrl, title, source, expires }, 30-min sliding TTL, prune-on-every-access; exports
|
||||||
|
317→ createStreamEntry / getStreamEntry / refreshStreamEntry.
|
||||||
|
318→- src/app/api/resolve/route.ts — JSON body { url } (≤2048, trim) → 400s; SSRF gate → 400;
|
||||||
|
319→ 10-min success cache on globalThis.__avaResolveCache (repeat links replay instantly);
|
||||||
|
320→ direct-audio bypass for .mp3 .m4a .aac .ogg .oga .opus .wav .flac .mp4 .webm (source "direct",
|
||||||
|
321→ title = decoded last path segment, fallback hostname); otherwise yt-dlp
|
||||||
|
322→ -J --no-warnings --no-playlist --no-progress --socket-timeout 15
|
||||||
|
323→ -f "bestaudio[protocol^=http]/bestaudio/best" with a 30 s kill → 504; non-zero exit →
|
||||||
|
324→ ANSI/control-stripped stderr tail (300 chars) → 422 ("Unsupported URL" gets its own message);
|
||||||
|
325→ stream URL pick = data.url ?? requested_formats[0].url ?? last http-protocol format;
|
||||||
|
326→ .m3u8/.mpd path or m3u8/mpd/dash protocol → 422 adaptive message; SSRF re-check on the
|
||||||
|
327→ RESOLVED stream URL (public CDNs like googlevideo pass) → 422 if not; title control-stripped +
|
||||||
|
328→ clamped 140 (fallback "Stream"), source = extractor_key|extractor|hostname lowercase,
|
||||||
|
329→ durationSec = numeric duration or null.
|
||||||
|
330→- src/app/api/audio/stream/route.ts — token → entry (prune first) else 404; SSRF re-check of the
|
||||||
|
331→ stored remoteUrl; forwards the client Range header, Chrome UA, redirect follow, no-store;
|
||||||
|
332→ !ok → 502 `Upstream failed (HTTP n).`; content-type must be audio/*, video/*,
|
||||||
|
333→ application/octet-stream or application/ogg else 502 "Upstream did not return audio.";
|
||||||
|
334→ refreshes sliding TTL, then passes through the upstream status with content-type/-length/
|
||||||
|
335→ -range/accept-ranges (only when upstream provided them) + Cache-Control: public, max-age=1800.
|
||||||
|
336→- FIX found while testing: the upstream host intermittently RST/ETIMEDOUTs full (non-ranged)
|
||||||
|
337→ fetches — verified against raw node fetch (1/6 ETIMEDOUT) — added ONE immediate retry on
|
||||||
|
338→ network-level failure only (HTTP error statuses are never retried); after that: 2× full GET
|
||||||
|
339→ 200 audio/mpeg 8,945,229 bytes.
|
||||||
|
340→- tsc --noEmit: zero errors in the new files (the project's 8 pre-existing errors are all in
|
||||||
|
341→ src/lib/ava, examples/, skills/, rust-scaffold — untouched).
|
||||||
|
342→
|
||||||
|
343→Stage Summary:
|
||||||
|
344→- yt-dlp: /home/z/my-project/bin/yt-dlp, version 2026.10.21 (runtime order: $AVA_YTDLP →
|
||||||
|
345→ <cwd>/bin/yt-dlp if existsSync → "yt-dlp" on PATH)
|
||||||
|
346→- Files created (nothing else touched, no frontend files): bin/yt-dlp, src/lib/server/ssrf.ts,
|
||||||
|
347→ src/lib/server/stream-store.ts, src/app/api/resolve/route.ts, src/app/api/audio/stream/route.ts
|
||||||
|
348→- Test 1 MUST-PASS direct MP3 (soundhelix Song-1): resolve → ok:true, source:"direct",
|
||||||
|
349→ title:"SoundHelix-Song-1.mp3"; full stream GET → 200 audio/mpeg 8,945,229 B (>1 MB; takes ~30 s
|
||||||
|
350→ because the origin throttles at ~300 KB/s — proxy adds ~0); Range 0-1023 → 206 with
|
||||||
|
351→ content-range "bytes 0-1023/8945229", accept-ranges, cache-control all verified
|
||||||
|
352→- Test 2 MUST-PASS SSRF: http://127.0.0.1/x → 400, http://169.254.169.254/latest/meta-data → 400,
|
||||||
|
353→ ftp://example.com/a.mp3 → 400. Extras: localhost:8080 → 400 (port), foo.bar.local → 400,
|
||||||
|
354→ [::1] → 400, [::ffff:10.0.0.5] → 400 (v4-mapped), 127.0.0.1.nip.io → 400 (DNS-result check),
|
||||||
|
355→ "not a url" / {} / {"url":5} / bad JSON → 400 "Expected { url } JSON body."
|
||||||
|
356→- Test 3 YouTube jNQXAC9IVRw: bot-blocked from this datacenter IP ("Sign in to confirm you're not
|
||||||
|
357→ a bot"). Tried the 2 extractor-args variants (youtube:player_client=web_embedded, then =tv)
|
||||||
|
358→ within the 5-minute budget — still blocked. Surfaces as a clean 422 with the sanitized tail:
|
||||||
|
359→ "Could not extract audio from that link: …Sign in to confirm you're not a bot…". Honest
|
||||||
|
360→ outcome: blocked here, ACCEPTABLE per spec (would work from residential IPs / with cookies).
|
||||||
|
361→- Test 4 SoundCloud forss/flickermood: yt-dlp extracts fine (title "Flickermood", 213.9 s) but
|
||||||
|
362→ this serve only exposes m3u8_native formats (hls_aac_96k/160k — confirmed in raw -J JSON) →
|
||||||
|
363→ clean 422 adaptive(HLS/DASH) message, exactly the manifest-only contract case.
|
||||||
|
364→- Test 5 https://example.com/ → clean 422 "That site is not supported — try YouTube, SoundCloud,
|
||||||
|
365→ or a direct MP3 link."; example.com/stream.m3u8 → 422 (does NOT hit the direct bypass).
|
||||||
|
366→- Extractor SUCCESS path proven end-to-end with archive.org: resolve → ok:true,
|
||||||
|
367→ title:"mp3 test file", source:"archiveorg", durationSec:12.42; stream → 200 audio/mpeg
|
||||||
|
368→ 198,658 B + 206 ranged; cache replay 16 ms.
|
||||||
|
369→- Stream negatives: unknown/missing token → 404 "Unknown or expired stream token"; upstream 500
|
||||||
|
370→ (missing file) → 502 "Upstream failed (HTTP 500)."
|
||||||
|
371→- lint: my 4 files pass eslint clean; `bun run lint` overall reports exactly 1 remaining error in
|
||||||
|
372→ src/components/ava/link-dialog.tsx:72 (set-state-in-effect) — that file is owned by the parallel
|
||||||
|
373→ frontend agent and was NOT touched by me per the ownership rules.
|
||||||
|
374→- dev.log: no exceptions/stack traces from the new routes — only expected
|
||||||
|
375→ 200/206/400/404/422/502 request lines.
|
||||||
|
376→- Deviations from spec (all deliberate): (1) one network-error retry in the proxy (upstream RSTs,
|
||||||
|
377→ never retries HTTP statuses); (2) third store helper refreshStreamEntry() — the two required
|
||||||
|
378→ helpers plus this one implements the specified "refresh sliding expiry AFTER upstream success";
|
||||||
|
379→ (3) proxy fetch uses cache:'no-store'; (4) 500 wording "Audio extractor unavailable (yt-dlp not
|
||||||
|
380→ found or failed to start)."; (5) success cache keyed by the normalized URL href (target.href).
|
||||||
|
381→
|
||||||
|
382→---
|
||||||
|
383→Task ID: 7
|
||||||
|
384→Agent: main (Z.ai Code)
|
||||||
|
385→Task: Frontend integration of the "Link" audio source (YouTube / SoundCloud / direct MP3)
|
||||||
|
386→
|
||||||
|
387→Work Log:
|
||||||
|
388→- Read worklog + all relevant files (audio.ts, store.ts, audio-source.ts, header.tsx,
|
||||||
|
389→ visual-stage.tsx, footer-bar.tsx, types.ts) before editing.
|
||||||
|
390→- src/lib/ava/types.ts — AudioSourceKind += 'link'
|
||||||
|
391→- src/lib/ava/audio.ts — new startUrl(streamUrl, title): same-origin proxied <audio> element →
|
||||||
|
392→ MediaElementAudioSourceNode → analyser (NOT cross-origin-tainted, so FFT works) → speakers;
|
||||||
|
393→ loops by default; public sourceLabel field (file name / stream title) maintained by all
|
||||||
|
394→ start* methods + stopSources
|
||||||
|
395→- src/lib/store.ts — AudioSourceState += 'link'; new sourceTitle + linkDialogOpen state
|
||||||
|
396→- src/components/ava/audio-source.ts — new resolveAndPlayLink(url): POST /api/resolve →
|
||||||
|
397→ engine.audio.startUrl → store sync; never throws (returns {ok,title,source,error}); on
|
||||||
|
398→ playback failure stops the engine and resets store; syncSource now also syncs sourceTitle
|
||||||
|
399→- src/components/ava/link-dialog.tsx — NEW: dialog with URL input + Load button (spinner),
|
||||||
|
400→ inline error box with YouTube bot-wall hint, RECENT LINKS chips (localStorage, max 5,
|
||||||
|
401→ one-click reload); body REMOUNTS on every open (fresh state — no setState-in-effect, fixes
|
||||||
|
402→ the lint error the Task 6 agent saw mid-build); results arriving after the user closed the
|
||||||
|
403→ dialog surface as toasts
|
||||||
|
404→- src/components/ava/header.tsx — "Link" segment button between File and Stop (opens dialog);
|
||||||
|
405→ <LinkDialog /> mounted in the header
|
||||||
|
406→- src/components/ava/visual-stage.tsx — idle overlay gains a 4th button "Stream a Link" +
|
||||||
|
407→ updated copy
|
||||||
|
408→- src/components/ava/footer-bar.tsx — SRC now shows "LINK · <title>" (truncated) for the
|
||||||
|
409→ active source; title also shown for File sources
|
||||||
|
410→- src/app/api/resolve/route.ts (main-agent touch-up, coordinated with Task 6's file): added
|
||||||
|
411→ BOT_WALL_RE mapping so YouTube's "Sign in to confirm you're not a bot" stderr returns a
|
||||||
|
412→ clean actionable 422 instead of a raw yt-dlp tail; client hint suppressed when the server
|
||||||
|
413→ message already explains the fix (no duplication)
|
||||||
|
414→
|
||||||
|
415→Stage Summary:
|
||||||
|
416→- Full link-source flow wired: header/idle-overlay → dialog → resolve → proxied playback →
|
||||||
|
417→ band analysis + beat detection + visuals, all client-side after resolution. Zero layout
|
||||||
|
418→ changes beyond the additive bits; lint clean.
|
||||||
|
419→
|
||||||
|
420→---
|
||||||
|
421→Task ID: 8
|
||||||
|
422→Agent: main (Z.ai Code)
|
||||||
|
423→Task: E2E verification (agent-browser) of the link source + regression checks
|
||||||
|
424→
|
||||||
|
425→Work Log:
|
||||||
|
426→- bun run lint → zero errors; dev.log clean (no exceptions)
|
||||||
|
427→- Initial load: header shows Demo/Mic/File/LINK/Stop; idle overlay has 4 buttons incl.
|
||||||
|
428→ "Stream a Link"; presets/tabs intact
|
||||||
|
429→- Link dialog: opens from idle overlay AND header; invalid input ("not a url") → inline
|
||||||
|
430→ client-side error "Paste a full http(s) link…"
|
||||||
|
431→- Direct MP3 (SoundHelix Song 1): resolve → dialog closes → toast → footer shows
|
||||||
|
432→ "SRC: LINK · SoundHelix-Song-1.mp3, FPS: 60, PTS: 480, BPM: 131.2" (real tempo ≈131 —
|
||||||
|
433→ beat detection locks from the STREAMED audio, proving the same-origin proxy keeps the
|
||||||
|
434→ analyser untainted); canvas luminance/bright-pixel counts change frame-to-frame (live
|
||||||
|
435→ animation); screenshot ava-link-playing.png
|
||||||
|
436→- Failed YouTube attempt while SoundHelix playing: music KEEPS playing (failed resolve
|
||||||
|
437→ never touches the engine) — verified footer still LINK @60fps after closing dialog
|
||||||
|
438→- RECENT LINKS: chip "DIRECT · SoundHelix-Song-1.mp3" appears on re-open; one-click reload
|
||||||
|
439→- YouTube jNQXAC9IVRw from this datacenter IP: clean 422 bot-wall message + actionable hint
|
||||||
|
440→ (verified twice — before/after the BOT_WALL_RE improvement)
|
||||||
|
441→- Beat predictor regression check: tap-tempo path (6×500 ms taps on the real Set Beat
|
||||||
|
442→ button) → "120.0 BPM, 98% confidence" → Stop → beat count 54→63 over 4.5 s, PRED badge,
|
||||||
|
443→ confidence decays 98→80% — "keeps the beat when the bass drops out" confirmed. (Note:
|
||||||
|
444→ auto-detect confidence on the 124 BPM demo stays low with the leftover test beatConfig
|
||||||
|
445→ ×1.35/210 ms — interval spread from the demo bassline; the designed learning path
|
||||||
|
446→ (Set Beat) locks instantly.)
|
||||||
|
447→- Console: zero errors (only HMR messages)
|
||||||
|
448→- Mobile 390×844: no horizontal scroll; footer pushed naturally by content and STICKS to
|
||||||
|
449→ viewport bottom when scrolled to end; VLM inspection of desktop + mobile screenshots
|
||||||
|
450→ confirms clean layout, no breakage
|
||||||
|
451→
|
||||||
|
452→Stage Summary:
|
||||||
|
453→- Link source fully verified in-browser: resolve → stream → analyze → visualize at 60 fps,
|
||||||
|
454→ beat detection on streamed audio, graceful YouTube bot-wall, recent-links, predictor
|
||||||
|
455→ regression pass. The only environmental limitation: YouTube blocks this datacenter IP
|
||||||
|
456→ (honest 422; works from residential IPs or with server-side cookies). SoundCloud/Bandcamp/
|
||||||
|
457→ archive.org/direct files all resolve; SoundCloud serves HLS-only for some tracks → clean
|
||||||
|
458→ adaptive-stream error.
|
||||||
|
459→
|
||||||
|
460→---
|
||||||
|
461→Task ID: 9
|
||||||
|
462→Agent: main (Z.ai Code)
|
||||||
|
463→Task: Preset library folder-tree data layer (backend + store) for the requested
|
||||||
|
464→"folder/file tree" Presets page redesign
|
||||||
|
465→
|
||||||
|
466→Work Log:
|
||||||
|
467→- Prisma schema: Preset += folderPath (default "root/user created") + originalFile,
|
||||||
|
468→ @@unique([folderPath, name]); new Folder model (path @unique). db:push OK,
|
||||||
|
469→ client regenerated, dev server restarted once to pick up the new client.
|
||||||
|
470→- src/lib/server/preset-tree.ts — shared helpers: normalizeFolderPath (root-prefixed,
|
||||||
|
471→ max depth 8, 80-char segments, control chars rejected), isValidName, parentOf,
|
||||||
|
472→ subtreeFilter (presets) + subtreeFolderFilter (folders), ensureFolderRows
|
||||||
|
473→ (creates missing ancestors), PROTECTED_FOLDERS = root | root/embedded | root/user created.
|
||||||
|
474→- GET /api/presets — one-time seed: folder backbone (root, root/embedded,
|
||||||
|
475→ root/user created, root/embedded/{Synth-C,Amp Picks,Dynamics,Envision}) +
|
||||||
|
476→ 95 presets (5 hand-ported classics → Synth-C; 90 auto-converted Google-Drive
|
||||||
|
477→ presets → Amp Picks 66 / Dynamics 9 / Envision 15, doubling prefix normalized).
|
||||||
|
478→ Legacy pre-folder built-ins migrated automatically. Response now
|
||||||
|
479→ { presets, folders } with folderPath + originalFile on each preset.
|
||||||
|
480→- POST /api/presets — create in folder (409 on name clash within folder).
|
||||||
|
481→- PUT /api/presets/[id] — update content/author/description or RENAME (unique
|
||||||
|
482→ per folder enforced); built-in restriction REMOVED (tree is fully manageable).
|
||||||
|
483→- DELETE /api/presets/[id] — any preset deletable.
|
||||||
|
484→- /api/folders — POST create subfolder (parent must exist), PATCH rename folder
|
||||||
|
485→ + all descendants (transaction, prefix rewrite), DELETE ?path= removes subtree
|
||||||
|
486→ (folders + presets). Protected folders reject rename/delete with 400.
|
||||||
|
487→- /api/presets/export?path=… — jszip download preserving the directory structure
|
||||||
|
488→ from root ("root/user created/Club Set/Test Ring.ava" style entries + dir entries).
|
||||||
|
489→- /api/presets/import (multipart file + targetPath) — imports .ava/.txt entries;
|
||||||
|
490→ "root/…"-prefixed paths land at their exact path, others under the target
|
||||||
|
491→ folder; creates missing folder rows; skips name-clash duplicates; reports
|
||||||
|
492→ { imported, skipped, skippedOther, foldersCreated }.
|
||||||
|
493→- src/lib/store.ts — PresetItem += folderPath/originalFile; EditTarget; new state:
|
||||||
|
494→ folders, selectedFolder, selectedPresetId, expandedFolders (depth<=2 default
|
||||||
|
495→ open), editTarget, activePresetId, randomize/randomIntervalSec/randomScope;
|
||||||
|
496→ actions selectFolder (also sets randomize scope live), selectPreset, setEditTarget,
|
||||||
|
497→ setActivePresetId, setRandomize, setRandomIntervalSec (3–60 s validation);
|
||||||
|
498→ module-level club-mode timer (syncRandomTimer/stopRandomTimer, loadRandomPreset
|
||||||
|
499→ applies to the ENGINE ONLY — never clobbers unsaved editor text; up to 4 random
|
||||||
|
500→ retries for compilable scripts; toast per switch); settings persisted to
|
||||||
|
501→ localStorage "ava.randomize" and restored via restoreRandomSettings(folders);
|
||||||
|
502→ refreshPresets now fetches presets+folders and prunes stale selections.
|
||||||
|
503→- src/app/page.tsx — initial load sets folders, startup fallback prefers the
|
||||||
|
504→ first root/embedded/Synth-C preset, sets activePresetId, restores randomize.
|
||||||
|
505→- VERIFIED via curl: seed 95/7 folders correct; create folder/preset; 409 dup;
|
||||||
|
506→ rename folder moves preset paths; export zip listing shows full paths; delete
|
||||||
|
507→ subtree; import restores the exact tree; protected-folder rename/delete → 400;
|
||||||
|
508→ export root → 200. tsc clean on all touched files.
|
||||||
|
509→
|
||||||
|
510→Stage Summary:
|
||||||
|
511→- API contract for the new UI:
|
||||||
|
512→ GET /api/presets → { presets: PresetItem[], folders: string[] }
|
||||||
|
513→ POST /api/presets { name, content, author?, description?, folderPath? } → { preset } | 409 | 400
|
||||||
|
514→ PUT /api/presets/[id] { name?, content?, author?, description? } → { preset }
|
||||||
|
515→ DELETE /api/presets/[id] → { deleted }
|
||||||
|
516→ POST /api/folders { path } → { folder } | 409 | 400
|
||||||
|
517→ PATCH /api/folders { path, newName } → { renamed, to, folders, presets }
|
||||||
|
518→ DELETE /api/folders?path=… → { deleted, folders, presets } (protected → 400)
|
||||||
|
519→ GET /api/presets/export?path=… → zip download (structure preserved from root)
|
||||||
|
520→ POST /api/presets/import (file, targetPath) → { imported, skipped, skippedOther, foldersCreated }
|
||||||
|
521→- Store API for the UI (src/lib/store.ts): useAvaStore fields folders,
|
||||||
|
522→ selectedFolder, selectedPresetId, expandedFolders, editTarget, activePresetId,
|
||||||
|
523→ randomize, randomIntervalSec, randomScope + actions listed above; helpers
|
||||||
|
524→ refreshPresets(), stopRandomTimer(), syncRandomTimer(), restoreRandomSettings().
|
||||||
|
525→- Random presets are applied engine-only (editor untouched) by design.
|
||||||
|
526→
|
||||||
|
527→---
|
||||||
|
528→Task ID: 10
|
||||||
|
529→Agent: full-stack-developer (Presets UI)
|
||||||
|
530→Task: Presets tab redesign — folder/file tree manager + Script tab Save-to-target / Save As
|
||||||
|
531→
|
||||||
|
532→Work Log:
|
||||||
|
533→- Read worklog (Task 9 contract), store.ts, all 5 API routes, old presets-tab,
|
||||||
|
534→ script-tab, shared.ts, control-panel.tsx, page.tsx, audio-source.ts,
|
||||||
|
535→ presets.ts, detect/beat tabs for style matching.
|
||||||
|
536→- src/components/ava/presets-tab.tsx — COMPLETE REWRITE (~1000 lines):
|
||||||
|
537→ - buildTree(folders, presets): synthesizes missing Folder rows + ancestors
|
||||||
|
538→ from preset folderPaths (defensive); segment-wise alphabetical sort
|
||||||
|
539→ (parents before children), presets sorted within folders; subtree preset
|
||||||
|
540→ counts per node.
|
||||||
|
541→ - TreeFolder/TreePreset recursive rows: chevron-only expand/collapse
|
||||||
|
542→ (store.toggleFolder; default open = path depth <= 2 segments, same rule
|
||||||
|
543→ as the store), row click selects (selectFolder/selectPreset), preset
|
||||||
|
544→ double-click loads, running preset = emerald Play icon + emerald-300 text
|
||||||
|
545→ + bg-emerald-400/10, selected rows = emerald left border + bg-zinc-800/70,
|
||||||
|
546→ subtree count badge (font-mono, tooltip "presets in this folder and
|
||||||
|
547→ below"), originalFile rows get a hover-visible (always on mobile) .avs
|
||||||
|
548→ download anchor, empty folders show "(empty)" italic hint. role=tree/
|
||||||
|
549→ treeitem/group, aria-expanded/selected/level, 36px (size-9/h-9) targets.
|
||||||
|
550→ Tree scrolls in max-h-[420px] lg:max-h-[480px] with thinScrollbar.
|
||||||
|
551→ - Two-column layout: tree 58% / actions 42% on sm+, stacked on mobile.
|
||||||
|
552→ - Context-sensitive actions panel (header shows selection + counts):
|
||||||
|
553→ preset -> LOAD PRESET (emerald; applyScriptText -> toast -> setActivePresetId
|
||||||
|
554→ + setEditTarget, compile error -> destructive toast + script tab), RENAME,
|
||||||
|
555→ DELETE; folder -> CREATE PRESET (POST starter template -> refresh ->
|
||||||
|
556→ editTarget + editor + engine + select + tab switch + toast), NEW SUBFOLDER
|
||||||
|
557→ (POST -> expand parent + select new), RENAME (PATCH folder / PUT preset,
|
||||||
|
558→ selection + editTarget follow the rename), DELETE (AlertDialog with
|
||||||
|
559→ client-side blast radius "removes N presets in M folders", protected
|
||||||
|
560→ folders root | root/embedded | root/user created have Rename+Delete
|
||||||
|
561→ disabled with "Protected folder" tooltip), EXPORT .ZIP (asChild anchor ->
|
||||||
|
562→ /api/presets/export?path=..., download attr), IMPORT .ZIP (hidden .zip
|
||||||
|
563→ input -> FormData POST -> summary toast with duplicates/foldersCreated).
|
||||||
|
564→ All server errors (409/400/404/500) surface as destructive toasts with
|
||||||
|
565→ the server message; empty names get inline dialog validation.
|
||||||
|
566→ - Randomize section (border-t): Checkbox + 3-60s delay input; invalid input
|
||||||
|
567→ shows red hint and reverts to the committed store value on blur (valid
|
||||||
|
568→ values commit live via setRandomIntervalSec); scope line reflects
|
||||||
|
569→ randomScope (folder name or "all presets"), amber "no presets in this
|
||||||
|
570→ folder yet" hint when scope empty; pulsing emerald "ON · every Ns" badge
|
||||||
|
571→ while running. Timer itself runs in the store — UI only wires controls.
|
||||||
|
572→ - NameDialog made fully controlled (parent owns value/error) to satisfy
|
||||||
|
573→ react-hooks/set-state-in-effect lint rule.
|
||||||
|
574→- src/components/ava/script-tab.tsx — EDITS:
|
||||||
|
575→ - "Saving to: root/…/Name" chip (emerald, middle-truncated path, full path
|
||||||
|
576→ in tooltip) with X button clearing the edit target; shown only while
|
||||||
|
577→ editTarget is set.
|
||||||
|
578→ - Save button (emerald, only with editTarget): PUT /api/presets/[id]
|
||||||
|
579→ {content, author from validateScript meta} -> refreshPresets + toast
|
||||||
|
580→ 'Saved to "Name"'; disabled while saving.
|
||||||
|
581→ - Save As… button (only with editTarget): Dialog prefilled with the current
|
||||||
|
582→ name -> POST /api/presets same folder -> setEditTarget(new) + refresh +
|
||||||
|
583→ toast; 409 handled; empty name inline error.
|
||||||
|
584→ - New button now also clears the edit target.
|
||||||
|
585→- VERIFIED in headless browser (agent-browser): tree render/expansion/
|
||||||
|
586→ selection, Load (toast + tab wiring), Save (PUT 200), Save As (copy
|
||||||
|
587→ created server-side + chip retargeted), delete preset + folder dialogs
|
||||||
|
588→ (blast-radius + empty-folder wording), create preset (full flow incl.
|
||||||
|
589→ editor + tab switch), new subfolder, rename folder (selection follows,
|
||||||
|
590→ editTarget path rewritten), import .zip end-to-end (DataTransfer dispatch
|
||||||
|
591→ into the hidden input -> 96 presets/8 folders -> toast -> cleanup),
|
||||||
|
592→ randomize persistence + scope line + invalid-delay revert + ON badge.
|
||||||
|
593→ Library restored to pristine 95 presets / 7 folders after testing.
|
||||||
|
594→- bun run lint: clean. tsc: clean on both touched files (pre-existing errors
|
||||||
|
595→ elsewhere in examples/, skills/, audio.ts, script.ts untouched).
|
||||||
|
596→ dev.log: no runtime errors.
|
||||||
|
597→
|
||||||
|
598→Stage Summary:
|
||||||
|
599→- Files modified: src/components/ava/presets-tab.tsx (full rewrite),
|
||||||
|
600→ src/components/ava/script-tab.tsx (chip + Save/Save As/New edits).
|
||||||
|
601→ No changes to page.tsx, store.ts, API routes, or other tabs.
|
||||||
|
602→- Key decisions: subtree (not direct) preset counts on folder badges
|
||||||
|
603→ (labeled via tooltip); NameDialog fully controlled to pass the new
|
||||||
|
604→ react-hooks lint rules; rename keeps editTarget/randomScope/selection
|
||||||
|
605→ pointing at the renamed paths client-side (refreshPresets prunes stale
|
||||||
|
606→ ones server-side); delay input commits valid values live but only reverts
|
||||||
|
607→ invalid text on blur so typing isn't fighted; export/import allowed on
|
||||||
|
608→ protected folders (rename/delete disabled) exactly per spec.
|
||||||
|
609→- Store/API notes for Task 11: none needed — the Task 9 contract covered
|
||||||
|
610→ everything. One cosmetic gap: setRandomIntervalSec is typed (sec: number)
|
||||||
|
611→ though the store handles numeric strings at runtime; UI converts with
|
||||||
|
612→ Number() before calling.
|
||||||
|
613→- Left for Task 11 verification: visual polish pass in a real browser
|
||||||
|
614→ (spacing at 420 vs 440px panel widths, mobile stacking), touch-device
|
||||||
|
615→ check of the hover-only .avs download button, and double-click-to-run
|
||||||
|
616→ ergonomics (agent-browser cannot dblclick, so that path was verified via
|
||||||
|
617→ the identical Load Preset code path).
|
||||||
|
618→
|
||||||
|
619→---
|
||||||
|
620→Task ID: 11
|
||||||
|
621→Agent: main (Z.ai Code)
|
||||||
|
622→Task: E2E verification of the new folder-tree Presets page + final cleanup
|
||||||
|
623→
|
||||||
|
624→Work Log:
|
||||||
|
625→- bun run lint: zero errors. tsc clean on all files touched by Tasks 9–10.
|
||||||
|
626→ dev.log: no runtime errors, no hydration warnings.
|
||||||
|
627→- Agent-browser E2E golden path, ALL VERIFIED:
|
||||||
|
628→ * Tree renders exactly as requested: root/ → embedded/ (Synth-C 5, Amp Picks 66,
|
||||||
|
629→ Dynamics 9, Envision 15) + user created/ 0; subtree count badges; depth≤2
|
||||||
|
630→ folders default-expanded; chevron-only expand/collapse; role=tree/treeitem.
|
||||||
|
631→ * Preset select → action panel switches to preset context (Load/Rename/Delete);
|
||||||
|
632→ folder select → full management set; protected folders (root/embedded/user
|
||||||
|
633→ created) have Rename+Delete disabled.
|
||||||
|
634→ * Load Preset: applied, toast, emerald Play-icon running indicator in tree,
|
||||||
|
635→ footer SCRIPT updated, engine running.
|
||||||
|
636→ * Create Preset: dialog → created in selected folder → auto-switch to Script
|
||||||
|
637→ tab with starter template → "Saving to: root/user created/…/Name" chip →
|
||||||
|
638→ edit → Save (PUT) persisted server-side (verified content round-trip) →
|
||||||
|
639→ Save As… created "Club Ring v2" in the same folder and retargeted the chip.
|
||||||
|
640→ * New Subfolder → "My Fav Presets" created + selected + toast.
|
||||||
|
641→ * Rename folder "My Fav Presets" → "Club Night": both presets' paths updated.
|
||||||
|
642→ * Delete folder: AlertDialog with blast radius ("removes 2 presets"), confirmed
|
||||||
|
643→ → folder + presets gone, library back to 95.
|
||||||
|
644→ * Export .zip: link href /api/presets/export?path=… correct; zip contains
|
||||||
|
645→ "root/user created/Round Trip/RT Preset.ava" (structure preserved from root).
|
||||||
|
646→ * Import .zip via the UI file input (DataTransfer dispatch): toast
|
||||||
|
647→ "Imported 1 presets — 1 folders created", tree restored at the exact path.
|
||||||
|
648→ * Randomize: invalid delay 2 and 61 both rejected with the "3 and 60 seconds"
|
||||||
|
649→ error + revert; 4 accepted; checkbox on → "ON · every 4s" badge; with Demo
|
||||||
|
650→ audio running, the timer switched to a random preset from the selected
|
||||||
|
651→ scope after ~4s (toast + footer PTS 333 matching the loaded script);
|
||||||
|
652→ scope line follows folder selection; settings persist to localStorage and
|
||||||
|
653→ do NOT auto-restart after reload unless enabled.
|
||||||
|
654→ * Mobile 390×844: no horizontal scroll, tree/actions stack, footer pushed
|
||||||
|
655→ naturally and sticks to viewport bottom when scrolled to the end.
|
||||||
|
656→ * Fresh reload: startup falls back to the first root/embedded/Synth-C preset
|
||||||
|
657→ (Gap The Mind), tree repopulates, zero console errors.
|
||||||
|
658→- Cleanup: deleted the Round Trip / Club Ring test data (library back to the
|
||||||
|
659→ pristine 95 presets / 7 folders); test artifacts in tool-results/.
|
||||||
|
660→
|
||||||
|
661→Stage Summary:
|
||||||
|
662→- The requested Presets page redesign is COMPLETE and browser-verified:
|
||||||
|
663→ left folder/file tree (root → embedded {Synth-C, Amp Picks, Dynamics,
|
||||||
|
664→ Envision} + user created) with counts, expansion, selection, running
|
||||||
|
665→ indicator and .avs original downloads; right management panel (Load Preset,
|
||||||
|
666→ Create Preset → Script tab → Save/Save As, New Subfolder, Rename, Delete with
|
||||||
|
667→ confirmation + blast radius, Export .zip preserving the tree, Import .zip
|
||||||
|
668→ restoring the tree) and Randomize (3–60 s validated) scoped to the selected
|
||||||
|
669→ folder. All flows verified end-to-end with zero console errors.
|
||||||
|
670→
|
||||||
|
671→---
|
||||||
|
672→Task ID: 12
|
||||||
|
673→Agent: main (Z.ai Code)
|
||||||
|
674→Task: LOST-WORK RECOVERY — restore the folder-tree Presets page (Tasks 9–11)
|
||||||
|
675→from the user's Google Drive project download after the sandbox was rebuilt
|
||||||
|
676→from an earlier snapshot
|
||||||
|
677→
|
||||||
|
678→Context:
|
||||||
|
679→- The sandbox running Tasks 9–11 was replaced by a rebuilt environment whose
|
||||||
|
680→ git history ended at the "link source" commit (51e07079 → local 4caaa3e).
|
||||||
|
681→ The two commits containing the Google-Drive preset import (b33474e) and the
|
||||||
|
682→ folder-tree implementation (2a23db4) existed only in the user's downloaded
|
||||||
|
683→ copy of the project — which the user kindly re-hosted on Google Drive.
|
||||||
|
684→
|
||||||
|
685→Work Log:
|
||||||
|
686→- Downloaded the user's archive (drive file 1yUmimOpLyz7l8BQO6keUHPCvLaV6TW5k,
|
||||||
|
687→ 86 MB tar.gz, includes .git with the two lost commits) to /tmp/ava-restore.
|
||||||
|
688→- Verified the archive's base commit (fb12d97, subject 51e07079) is
|
||||||
|
689→ content-identical to the workspace HEAD 4caaa3e (git diff empty except
|
||||||
|
690→ .zscripts/tool-results) → archive HEAD is a strict superset, safe to restore.
|
||||||
|
691→- Stopped the dev server, then surgically checked out all 200 files changed
|
||||||
|
692→ between fb12d97 → 2a23db4 (excluding .zscripts/dev.pid, tool-results,
|
||||||
|
693→ dev.log): prisma/schema.prisma (Folder model + Preset.folderPath/originalFile),
|
||||||
|
694→ db/custom.db (pre-seeded 95 presets / 7 folders), src/lib/server/preset-tree.ts,
|
||||||
|
695→ src/lib/ava/embedded-presets.ts, src/lib/ava/{engine,expression,script}.ts
|
||||||
|
696→ (functions needed by the converted presets), src/app/api/{folders,presets/**
|
||||||
|
697→ export,import}/route.ts, presets-tab.tsx (full tree rewrite), script-tab.tsx
|
||||||
|
698→ (Save/Save As chip), store.ts (tree + randomize state), page.tsx, plus the
|
||||||
|
699→ 95 .avs originals under public/avs-originals/… and tools/convert-avs.ts.
|
||||||
|
700→- Ran `bun run db:generate` to rebuild the Prisma client for the new schema
|
||||||
|
701→ (the restored db already matched it — no db:push needed, no data loss).
|
||||||
|
702→- Restarted the dev server (port 3000, single instance).
|
||||||
|
703→- VERIFIED via API: GET /api/presets → 95 presets / 7 folders
|
||||||
|
704→ (Synth-C 5, Amp Picks 66, Dynamics 9, Envision 15); export
|
||||||
|
705→ root/embedded/Synth-C → zip with full "root/embedded/Synth-C/*.ava" paths;
|
||||||
|
706→ import of the same zip → 0 imported / 5 skipped (duplicate paths) — round
|
||||||
|
707→ trip intact.
|
||||||
|
708→- VERIFIED in agent-browser (desktop 1440×900 + mobile 390×844): tree renders
|
||||||
|
709→ (root 95 → embedded {Amp Picks 66, Dynamics 9, Envision 15, Synth-C 5} +
|
||||||
|
710→ user created), preset select → Load Preset → engine running (footer
|
||||||
|
711→ "SRC: DEMO, FPS: 60, PTS: 480, BPM: 126.8, SCRIPT: Gap The Mind, engine ok"),
|
||||||
|
712→ New Subfolder ("My Fav Presets" created + selected), Rename (→ "Club Night",
|
||||||
|
713→ toast shows new path), Delete (alertdialog + server 200, tree cleaned),
|
||||||
|
714→ Randomize delay validation ("2" → red "Delay must be between 3 and 60
|
||||||
|
715→ seconds." hint; "4" commits), .avs original HEAD → 200, reload → startup
|
||||||
|
716→ fallback loads Gap The Mind, zero console/page errors, mobile: no horizontal
|
||||||
|
717→ scroll, footer pushed naturally and flush with the viewport bottom when
|
||||||
|
718→ scrolled to end. bun run lint: clean.
|
||||||
|
719→- Test data cleaned up (Club Night folder deleted; library back to 95/7).
|
||||||
|
720→- Committed the restoration to git and left the recovery remote/objects
|
||||||
|
721→ out of the repo (temp files remain in /tmp/ava-restore only).
|
||||||
|
722→
|
||||||
|
723→Stage Summary:
|
||||||
|
724→- The folder-tree Presets page (Tasks 9–11) is fully restored and re-verified
|
||||||
|
725→ end-to-end. Root cause of the "revert": the environment was rebuilt from a
|
||||||
|
726→ snapshot predating the morning's commits; the work itself was never lost in
|
||||||
|
727→ the user's download. All state (schema, data, API, UI, presets, tooling)
|
||||||
|
728→ now matches the archive's HEAD (2a23db4) on top of the current workspace.
|
||||||
|
729→- NOTE for the future: commit snapshots are the source of truth when an
|
||||||
|
730→ environment rebuild is suspected — compare git log before re-implementing.
|
||||||
|
731→
|
||||||
|
732→---
|
||||||
|
733→Task ID: 13
|
||||||
|
734→Agent: main (Z.ai Code)
|
||||||
|
735→Task: v6.0 — rename to "Open Visualization Engine" (AVA → OVE) + footer
|
||||||
|
736→version indicator (establishes the versioning convention)
|
||||||
|
737→
|
||||||
|
738→Work Log:
|
||||||
|
739→- New src/lib/version.ts — single source of truth: APP_NAME
|
||||||
|
740→ "Open Visualization Engine", APP_SHORT "OVE", APP_VERSION "6.0" + a
|
||||||
|
741→ short version-history comment (6.0 rename+stamp, 5.0 folder-tree, 4.0
|
||||||
|
742→ link source) so future bumps are a one-line change.
|
||||||
|
743→- src/app/layout.tsx — document title now "OVE — Open Visualization Engine
|
||||||
|
744→ (v6.0)" (built from the constants); description + keywords updated
|
||||||
|
745→ (AVA→OVE, "agent"→"engine").
|
||||||
|
746→- src/components/ava/header.tsx — logo block h1 "AVA" → "OVE" (APP_SHORT),
|
||||||
|
747→ subtitle "ADVANCED VISUALIZATION AGENT" → "OPEN VISUALIZATION ENGINE"
|
||||||
|
748→ (APP_NAME.toUpperCase()), both imported from version.ts.
|
||||||
|
749→- src/components/ava/footer-bar.tsx — right cluster is now a ml-auto wrapper
|
||||||
|
750→ holding: "version 6.0" stamp (text-zinc-600, tooltip "Open Visualization
|
||||||
|
751→ Engine — version 6.0") immediately LEFT of the engine-ok dot + label,
|
||||||
|
752→ exactly as requested. Stamp is visible on mobile too (fits: no horizontal
|
||||||
|
753→ scroll at 390px).
|
||||||
|
754→- Internal code identifiers (ava store/lib/component paths, ava-agent rust
|
||||||
|
755→ crate) intentionally left unchanged — the rename is user-facing only.
|
||||||
|
756→- VERIFIED in agent-browser: title "OVE — Open Visualization Engine (v6.0)";
|
||||||
|
757→ header logo/subtitle; footer text "...version 6.0\nengine ok" with the
|
||||||
|
758→ stamp on the same row, right of the stats, left of the dot; mobile 390×844
|
||||||
|
759→ no horizontal scroll + stamp visible; zero console/page errors; lint clean.
|
||||||
|
760→- Committed as "v6.0 — rename to Open Visualization Engine + version stamp".
|
||||||
|
761→
|
||||||
|
762→Stage Summary:
|
||||||
|
763→- Versioning convention established: bump APP_VERSION in src/lib/version.ts
|
||||||
|
764→ on every large change; the stamp renders bottom-right of the footer and the
|
||||||
|
765→ document title carries it too. Users can name local backups by the stamp
|
||||||
|
766→ (e.g. "OVE v6.0") and cross-check against z.ai Version History.
|
||||||
|
767→
|
||||||
|
768→---
|
||||||
|
769→Task ID: 14
|
||||||
|
770→Agent: main (Z.ai Code)
|
||||||
|
771→Task: v6.1 — preset library quality pass: every embedded stand-in is now a
|
||||||
|
772→UNIQUE visual (user reported "Containment" and "Dragonscales" look identical)
|
||||||
|
773→
|
||||||
|
774→Root cause:
|
||||||
|
775→- The old converter had only 6 fixed fallback templates chosen by
|
||||||
|
776→ seed % 6, so 76 of 90 embedded presets collapsed into 6 identical visuals
|
||||||
|
777→ (19×swirl, 16×pulse-rings, 14×starfield, 12×lattice, 10×kaleido, 5×tunnel);
|
||||||
|
778→ library-wide only 25 of 95 distinct script bodies.
|
||||||
|
779→
|
||||||
|
780→Work Log:
|
||||||
|
781→- tools/convert-avs.ts — REPLACED the fallback generator:
|
||||||
|
782→ * 16 geometric families (pulse rings, tunnel, starfield, lattice, swirl,
|
||||||
|
783→ kaleido, lissajous, spectrum bars, moire, fountain, rose, orbits, ribbon,
|
||||||
|
784→ web, rain, helix), each parameterized.
|
||||||
|
785→ * 6 palettes (rainbow / fire / ice / neon-band / mono / duotone).
|
||||||
|
786→ * Every numeric parameter drawn from mulberry32 seeded by hashStr(preset
|
||||||
|
787→ name) — same name regenerates the same visual forever; different names
|
||||||
|
788→ always differ (counts, speeds, twists, couplings, widths, persistence).
|
||||||
|
789→ * Family chosen from the original's unportable components (BlitterFeedback
|
||||||
|
790→ → tunnel/kaleido/moire, Movement → swirl/ribbon/web, Mirror → kaleido,
|
||||||
|
791→ DotFountain → fountain, …) + guaranteed full 16-family rotation per run
|
||||||
|
792→ (unused-families first) + 25% wild card.
|
||||||
|
793→ * Every generated fallback is smoke-tested; a known-good safeFallback is
|
||||||
|
794→ the last resort (never emits a broken script).
|
||||||
|
795→ * Fixed during regen: getspec() needs 3 args (spectrum family); tiny-n
|
||||||
|
796→ guard (ported scopes rendering <16 points now also become stand-ins —
|
||||||
|
797→ 5 more presets rescued); folder path no longer double-prefixed
|
||||||
|
798→ ("root/embedded/root/embedded/…" → clean at the source); same-path
|
||||||
|
799→ copyFileSync guard so the converter can re-run in place.
|
||||||
|
800→- Regenerated src/lib/ava/embedded-presets.ts from public/avs-originals/
|
||||||
|
801→ embedded/ (90 presets: 9 ported original-code + 81 unique stand-ins; all
|
||||||
|
802→ 16 families used, max 9 presets per family, 90/90 distinct bodies).
|
||||||
|
803→- prisma/schema.prisma — Preset.approx Boolean (marks stand-ins), Settings.
|
||||||
|
804→ embeddedSeed String? (seed-version marker). db push + client regenerated;
|
||||||
|
805→ dev server restarted once to pick up the new client.
|
||||||
|
806→- GET /api/presets — ensureSeeded is now VERSION-GATED: when Settings.
|
||||||
|
807→ embeddedSeed !== EMBEDDED_SEED_VERSION ('6.1', exported from
|
||||||
|
808→ embedded-presets.ts), the root/embedded subtree + stray built-ins are
|
||||||
|
809→ deleted and re-seeded from BUILTIN + EMBEDDED (user presets untouched).
|
||||||
|
810→ Future library regenerations propagate automatically on next request.
|
||||||
|
811→ Response now includes approx per preset.
|
||||||
|
812→- src/lib/store.ts — PresetItem.approx?: boolean.
|
||||||
|
813→- src/components/ava/presets-tab.tsx — amber "≈" badge on tree rows for
|
||||||
|
814→ approx presets (tooltip explains: original relies on effects the script
|
||||||
|
815→ engine can't run; uniquely generated stand-in).
|
||||||
|
816→- src/lib/version.ts — APP_VERSION '6.1'.
|
||||||
|
817→- VERIFIED: API re-seed → 95 presets / 7 folders, 81 approx + 14 original
|
||||||
|
818→ code, 95/95 distinct bodies, Containment ≠ Dragonscales (different
|
||||||
|
819→ families + parameters); VLM image comparison of live screenshots confirms
|
||||||
|
820→ clearly different visuals (organic single loop vs woven orbital knot);
|
||||||
|
821→ Kingish (594 pts) renders; ≈ badges (60 of 66 Amp Picks rows); Randomize
|
||||||
|
822→ over root scope switches presets at 3 s (Kingish → Plankton); export zip
|
||||||
|
823→ 66 entries with clean paths; zero console/page errors; lint clean.
|
||||||
|
824→
|
||||||
|
825→Stage Summary:
|
||||||
|
826→- Every embedded preset is now visually unique (95/95 distinct bodies).
|
||||||
|
827→- Honesty layer: approx stand-ins are badged "≈" in the tree; descriptions
|
||||||
|
828→ name the generated family; the original .avs download remains one click.
|
||||||
|
829→- Regeneration pipeline: bun tools/convert-avs.ts public/avs-originals/
|
||||||
|
830→ embedded → bump SEED_VERSION in the converter → DB refreshes itself via
|
||||||
|
831→ the embeddedSeed marker on the next GET /api/presets.
|
||||||
|
832→- 9 error files (parser "out of bytes") remain excluded — parser hardening
|
||||||
|
833→ is future work if those originals matter.
|
||||||
|
834→
|
||||||
1099
tool-results/read_1793086294893_e6288764dc88.txt
Normal file
199
worklog.md
|
|
@ -831,3 +831,202 @@ Stage Summary:
|
||||||
the embeddedSeed marker on the next GET /api/presets.
|
the embeddedSeed marker on the next GET /api/presets.
|
||||||
- 9 error files (parser "out of bytes") remain excluded — parser hardening
|
- 9 error files (parser "out of bytes") remain excluded — parser hardening
|
||||||
is future work if those originals matter.
|
is future work if those originals matter.
|
||||||
|
|
||||||
|
---
|
||||||
|
Task ID: 15-11
|
||||||
|
Agent: general-purpose (E2E verifier)
|
||||||
|
Task: v6.2 security hardening — end-to-end browser + API verification
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- Read worklog Tasks 9–14 + v6.2 sources (presets-tab.tsx, script-tab.tsx,
|
||||||
|
user-presets.ts, api routes) read-only to know exact button/toast wording.
|
||||||
|
- A. curl probes of every mutation endpoint (POST/PUT/DELETE presets,
|
||||||
|
POST/DELETE folders, import, settings, GET presets shape).
|
||||||
|
- B. agent-browser desktop session at 1440×900: golden path exactly per
|
||||||
|
checklist — embedded folder/preset panels, lock/User badges (DOM
|
||||||
|
svg.lucide-lock / svg.lucide-user), Copy to my presets, dblclick load,
|
||||||
|
user-folder management (Create Preset → Script tab → Save), rename,
|
||||||
|
embedded Save block toast + Save As copy, subfolder + nested preset +
|
||||||
|
blast-radius delete, preset delete, Survivor persistence across reload,
|
||||||
|
full cleanup, client-side zip export toast, localStorage JSON check,
|
||||||
|
Randomize delay validation (2 vs 5) + uncheck. Screenshots + VLM visual
|
||||||
|
cross-checks in tool-results/e2e-v62/.
|
||||||
|
- C. Mobile 390×844: scrollWidth check, tree/panel stacking geometry,
|
||||||
|
footer position (first paint vs scrolled-to-end), lock badges, panel
|
||||||
|
interaction; VLM visual confirmation of 07-mobile-firstpaint.png.
|
||||||
|
- D. dev.log reviewed over the whole session window (baseline line 288 →
|
||||||
|
end 396): only prisma:query + GET 200s; grepped for error/warn/exception.
|
||||||
|
- Final state: browser localStorage ove.userPresets.v1 =
|
||||||
|
{"v":1,"folders":[],"presets":[]} (user created empty); server library
|
||||||
|
pristine 95 presets / 7 folders, zero user-created rows.
|
||||||
|
|
||||||
|
Results:
|
||||||
|
- A1..A4: PASS. A1 POST /api/presets → 403 "The server preset library is
|
||||||
|
read-only. Your presets are saved in your browser under "user created"."
|
||||||
|
A2 DELETE /api/folders?path=root/embedded → 403 (same class of message).
|
||||||
|
A3 GET /api/settings → 404 (Next 404 page; title "OVE — … (v6.2)").
|
||||||
|
A4 GET /api/presets → 200, exactly 95 presets, folders exactly
|
||||||
|
[root, root/embedded, root/embedded/{Amp Picks, Dynamics, Envision,
|
||||||
|
Synth-C}, root/user created], 0 presets under root/user created.
|
||||||
|
Extra probes: PUT & DELETE /api/presets/[id] → 403; POST /api/folders →
|
||||||
|
403; POST /api/presets/import → 403; PUT /api/folders → 405 (no handler —
|
||||||
|
mutation still blocked); GET /api/presets/export → 200 (read-only legacy
|
||||||
|
endpoint still present; UI export is client-side as built).
|
||||||
|
- B5..B21: ALL PASS.
|
||||||
|
B5 title/header "OVE", footer "version 6.2 | engine ok", tree embedded(95)
|
||||||
|
+ subfolders with 🔒 lock icons on root/embedded/×4 subfolders (user
|
||||||
|
created without), user created(0), 0 console errors.
|
||||||
|
B6 embedded folder → read-only note, only Export .zip + Import .zip (no
|
||||||
|
Create/Rename/Delete), READ-ONLY chip (VLM-confirmed).
|
||||||
|
B7 embedded preset (Kingish) → Load Preset + Copy to my presets, no
|
||||||
|
Rename/Delete, READ-ONLY chip.
|
||||||
|
B8 toast "Copied to your presets | ""Kingish (copy)"" is now yours to
|
||||||
|
edit — stored in this browser only."; copy appears under user created
|
||||||
|
with User icon.
|
||||||
|
B9 dblclick copy → "Preset loaded | ""Kingish (copy)"" is now running."
|
||||||
|
B10 user created folder → Create Preset + New Subfolder enabled, Rename +
|
||||||
|
Delete shown but disabled (protected root, tooltip explains).
|
||||||
|
B11 Create "Test Preset E2E" → "Preset created", auto-switch to Script
|
||||||
|
tab with "Saving to: root/user created/Test Preset E2E" chip.
|
||||||
|
B12 Save → "Saved to ""Test Preset E2E"" …" + updatedAt>createdAt in
|
||||||
|
localStorage (PASS on retry — see issue 1).
|
||||||
|
B13 Rename → "Preset renamed | ""Test Preset E2E"" is now ""Test Renamed""."
|
||||||
|
B14 embedded target Save → destructive "Embedded presets are read-only |
|
||||||
|
Use ""Save As…"" — it stores your copy under ""user created"" …";
|
||||||
|
Save As… (name kept) → "Copy saved | ""Plankton"" created in
|
||||||
|
root/user created (this browser)." + chip retargeted.
|
||||||
|
B15 E2E Folder created; "Nested One" created inside it; folder delete
|
||||||
|
AlertDialog shows blast radius ("removes 1 preset") → deleted.
|
||||||
|
B16 "Test Renamed" deleted after confirm → "Preset deleted".
|
||||||
|
B17 "Survivor" survived full page reload under user created (badge 4).
|
||||||
|
B18 cleanup: Survivor, Plankton, Kingish (copy), Kingish all deleted →
|
||||||
|
localStorage {"v":1,"folders":[],"presets":[]}, tree "user created 0".
|
||||||
|
B19 embedded folder Export .zip → "Export ready | 95 presets zipped and
|
||||||
|
downloading." 0 console/page errors.
|
||||||
|
B20 ove.userPresets.v1 valid JSON {v:1, folders:[], presets:0}.
|
||||||
|
B21 delay "2" → red "Delay must be between 3 and 60 seconds." (committed
|
||||||
|
value kept at 10s); "5" → error gone, badge "ON · every 5s"; uncheck OK.
|
||||||
|
- C22: PASS (with wording note). 390×844: scrollWidth 390 = clientWidth 390
|
||||||
|
(no horizontal scroll); tree + management panel stack vertically (both
|
||||||
|
320px wide) and stay usable (embedded select → note + Export/Import);
|
||||||
|
6 lock badges render (VLM-confirmed). Footer: page content is 1313px tall
|
||||||
|
so the footer is below the fold on first paint; it is flush with the
|
||||||
|
document bottom and fully visible when scrolled to the end
|
||||||
|
(footerBottom = 844 = viewportH). Desktop 1440×900: footer IS within the
|
||||||
|
first paint (doc height exactly 900). Same mobile behavior as accepted in
|
||||||
|
Tasks 11–13.
|
||||||
|
- D23: PASS. Session window of dev.log contains only prisma:query lines and
|
||||||
|
GET 200s; zero error/warn/exception lines in the entire log; the single
|
||||||
|
"[presets] embedded library drift detected — re-seeding from source" at
|
||||||
|
line 251 predates this session (expected/informational). Repeated DELETE
|
||||||
|
prisma lines on every GET are purgeVisitorJunk()'s indexed deleteMany —
|
||||||
|
by design (v6.2 self-heal).
|
||||||
|
|
||||||
|
Issues found:
|
||||||
|
- 1 (environmental, not an app bug): during the session the dev server
|
||||||
|
pushed 4 "[Fast Refresh] rebuilding" HMR updates (delayed propagation of
|
||||||
|
the 07:25–07:29 source changes; session began 07:31, no file changed
|
||||||
|
after 07:29:46). One rebuild landed exactly on the first B12 "Save"
|
||||||
|
click: the zustand store was re-initialized, editTarget cleared (chip +
|
||||||
|
Save/Save As… buttons vanished) and no toast/save occurred. Re-establishing
|
||||||
|
the target via Load Preset and re-clicking Save worked flawlessly with
|
||||||
|
zero console errors. No app fix needed; restarting the dev server before
|
||||||
|
future E2E sessions would avoid the race.
|
||||||
|
- 2 (observation): PUT /api/folders returns 405 instead of 403 (route
|
||||||
|
exports no PUT). Mutation is still blocked; cosmetic difference only.
|
||||||
|
- 3 (observation): GET /api/presets/export still answers 200 server-side
|
||||||
|
(read-only zip download). The UI export path is client-side JSZip as
|
||||||
|
built; the legacy endpoint is harmless but could be removed for a fully
|
||||||
|
client-side story.
|
||||||
|
- 4 (observation): on mobile 390×844 the footer is not within the first
|
||||||
|
viewport paint (content is 1313px tall); it is the last element, flush
|
||||||
|
with the document bottom when scrolled to the end. If "footer visible on
|
||||||
|
first paint" is a hard mobile requirement, the layout would need a
|
||||||
|
sticky-footer redesign.
|
||||||
|
- 5 (observation): "user created" root folder's Rename/Delete buttons are
|
||||||
|
rendered disabled by design ("root of your library" protection) — buttons
|
||||||
|
are present as the checklist expects, just not enabled for the root
|
||||||
|
itself; user PRESETS get enabled Rename/Delete.
|
||||||
|
|
||||||
|
---
|
||||||
|
Task ID: 15
|
||||||
|
Agent: main (Z.ai Code)
|
||||||
|
Task: v6.2 — security hardening for the shared VPS (openvisengine.com): protect
|
||||||
|
the embedded library from visitor edits/renames/deletes, and move "user
|
||||||
|
created" presets into each visitor's own browser so cross-visitor spam is
|
||||||
|
impossible.
|
||||||
|
|
||||||
|
User requirement:
|
||||||
|
- Deleting/renaming/adding presets under embedded/ must not affect other
|
||||||
|
visitors. User presets must live in the visitor's "temporary files" (site-
|
||||||
|
accessible browser storage). No visitor-created garbage visible to others.
|
||||||
|
|
||||||
|
Work Log:
|
||||||
|
- src/lib/preset-paths.ts (NEW, pure module shared client+server): ROOT,
|
||||||
|
EMBEDDED_ROOT, USER_ROOT constants + isEmbeddedPath/isUserAreaPath +
|
||||||
|
client-side twins of normalizeFolderPath/isValidName.
|
||||||
|
- src/lib/server/preset-tree.ts: constants now sourced from preset-paths;
|
||||||
|
re-exports the helpers; doc updated (read-only model).
|
||||||
|
- src/lib/ava/user-presets.ts (NEW): the browser-stored user library —
|
||||||
|
localStorage key ove.userPresets.v1, shape-checked + self-healing reads,
|
||||||
|
quota-guarded writes, ids "local-<uuid>", caps (200 presets / 100 folders /
|
||||||
|
128 KB per script), full CRUD (create/rename/delete preset, create/rename/
|
||||||
|
delete folder with subtree moves + depth guard, zip-import batch). Spam
|
||||||
|
physically cannot reach the server: nothing here ever calls fetch.
|
||||||
|
- src/lib/ava/preset-io.ts (NEW): client-side JSZip export (builds zip from
|
||||||
|
the merged tree, browser download) + import (unpacks zip in-browser;
|
||||||
|
remaps every entry into root/user created — embedded paths become the
|
||||||
|
visitor's editable copies; 50 MB / 2000-entry caps).
|
||||||
|
- src/lib/store.ts: PresetItem unchanged; refreshPresets now MERGES server
|
||||||
|
embedded library (user-area rows filtered out defensively) with
|
||||||
|
listUserPresets() and allUserFolderPaths(); applyUserPresets() re-merges
|
||||||
|
after local CRUD; settings persistence (band/beat/default script/randomize)
|
||||||
|
is now localStorage-only — the debounced PUT /api/settings layer was
|
||||||
|
deleted; stale randomScope heals to root/user created.
|
||||||
|
- src/app/page.tsx: useInitialLoad now uses refreshPresets() (merged tree) and
|
||||||
|
reads configs exclusively from localStorage; no /api/settings fetch.
|
||||||
|
- Server hardening:
|
||||||
|
* GET /api/presets — ensureSeeded upgraded with a FINGERPRINT self-heal:
|
||||||
|
embedded subtree must exactly match BUILTIN+EMBEDDED (folder+name), else
|
||||||
|
re-seeds (heals pre-hardening renames/deletes/injections even without a
|
||||||
|
version bump); purgeVisitorJunk() deletes any isBuiltIn=false rows,
|
||||||
|
user-area presets, and non-backbone folders on every GET; response serves
|
||||||
|
only isBuiltIn rows in root/embedded/** + the 7 backbone folders.
|
||||||
|
* POST /api/presets, PUT/DELETE /api/presets/[id], POST/PUT/PATCH/DELETE
|
||||||
|
/api/folders, POST /api/presets/import → 403 with explanatory messages.
|
||||||
|
* /api/settings route DELETED (404). /api/presets/export kept as a
|
||||||
|
read-only maintainer backup endpoint (UI no longer uses it).
|
||||||
|
- src/components/ava/presets-tab.tsx rework: lock badge on root/embedded
|
||||||
|
folders; "read-only" chip in the panel; embedded folder → informational
|
||||||
|
note + Export/Import only; embedded preset → Load + "Copy to my presets"
|
||||||
|
(auto-uniquified name, lands in user created); user presets flagged with a
|
||||||
|
User icon and get Rename/Delete; all CRUD + zip import/export now local
|
||||||
|
(user-presets + preset-io libs, applyUserPresets re-merge); user created
|
||||||
|
empty-state hint; delete confirms say "from this browser".
|
||||||
|
- src/components/ava/script-tab.tsx: Save on an embedded target → blocking
|
||||||
|
toast directing to Save As; Save As always lands in the user area
|
||||||
|
(embedded/root targets redirect to root/user created); saves are
|
||||||
|
updateUserPreset/createUserPreset (browser).
|
||||||
|
- src/lib/version.ts → 6.2 (+history line). detect-tab.tsx stale comment fixed.
|
||||||
|
- VERIFIED (subagent E2E, Task 15-11, 23/23 PASS): all curl mutations 403
|
||||||
|
(incl. PUT folders after cosmetic fix); GET serves 95 presets/7 folders/zero
|
||||||
|
user-area rows; junk-seeded DB (spam preset, injected row, vandal-renamed
|
||||||
|
"Gap The Mind", junk folders) fully self-healed on next GET; browser flows:
|
||||||
|
lock badges, read-only panel, Copy to my presets, create/rename/delete
|
||||||
|
preset + folder (incl. nested blast radius), Save-blocked-on-embedded +
|
||||||
|
Save As redirect, persistence across reload, client export toast (95 zipped),
|
||||||
|
randomize 3–60s validation, mobile 390×844 no horizontal scroll, zero
|
||||||
|
console errors; lint clean.
|
||||||
|
- Committed as "v6.2 — read-only embedded library + per-visitor browser presets".
|
||||||
|
|
||||||
|
Stage Summary:
|
||||||
|
- Threat model closed: the server accepts ZERO visitor writes (403 on every
|
||||||
|
mutation endpoint), the embedded library self-heals against drift (rename/
|
||||||
|
delete/inject → fingerprint re-seed), and each visitor's presets live in
|
||||||
|
their own localStorage (merge is client-side). Spam/vandalism cannot cross
|
||||||
|
visitors by construction.
|
||||||
|
- Migration note for the VPS: previously server-stored user presets are
|
||||||
|
abandoned (purged by self-heal); visitors re-import via zip if needed.
|
||||||
|
Settings (band/beat/default script) are per-browser now.
|
||||||
|
- /api/presets/export intentionally kept: read-only maintainer backup of the
|
||||||
|
embedded library.
|
||||||
|
|
|
||||||