diff --git a/db/custom.db b/db/custom.db index ef73d91..aed7f07 100755 Binary files a/db/custom.db and b/db/custom.db differ diff --git a/src/app/api/folders/route.ts b/src/app/api/folders/route.ts index c183556..8bfd2b1 100755 --- a/src/app/api/folders/route.ts +++ b/src/app/api/folders/route.ts @@ -1,131 +1,28 @@ import { NextRequest, NextResponse } from 'next/server' -import { db } from '@/lib/db' -import { - PROTECTED_FOLDERS, - isValidName, - normalizeFolderPath, - parentOf, - subtreeFilter, - subtreeFolderFilter, -} from '@/lib/server/preset-tree' export const dynamic = 'force-dynamic' -/** Create a subfolder inside an existing folder. Body: { path: "root/a/b" } */ -export async function POST(req: NextRequest) { - try { - const body = await req.json() - const folder = normalizeFolderPath(body?.path) - if (!folder) { - return NextResponse.json({ error: 'Invalid folder path' }, { status: 400 }) - } - if (folder.path === 'root') { - return NextResponse.json({ error: '"root" already exists' }, { status: 409 }) - } - if (PROTECTED_FOLDERS.has(folder.path)) { - return NextResponse.json({ error: 'That folder already exists' }, { status: 409 }) - } - const existing = await db.folder.findUnique({ where: { path: folder.path } }) - if (existing) { - return NextResponse.json({ error: `"${folder.path}" already exists` }, { status: 409 }) - } - const parent = parentOf(folder.segments) - const parentRow = await db.folder.findUnique({ where: { path: parent } }) - if (!parentRow) { - return NextResponse.json( - { error: `Parent folder "${parent}" does not exist` }, - { status: 400 }, - ) - } - await db.folder.create({ data: { path: folder.path } }) - return NextResponse.json({ folder: { path: folder.path } }, { status: 201 }) - } catch (err) { - console.error('POST /api/folders failed:', err) - return NextResponse.json({ error: 'Failed to create folder' }, { status: 500 }) - } +const READ_ONLY_MSG = + 'The server folder tree is read-only. Your folders live in your browser under "user created".' + +/** + * READ-ONLY tree (v6.2): no folder on the shared server may be created, + * renamed, or deleted through the public API — otherwise one visitor could + * restructure (or wipe) the embedded library for everybody. Each visitor's + * own folders are managed entirely inside their browser. + */ +export async function POST(_req: NextRequest) { + return NextResponse.json({ error: READ_ONLY_MSG }, { status: 403 }) } -/** Rename a folder (and everything below it). Body: { path, newName } */ -export async function PATCH(req: NextRequest) { - try { - const body = await req.json() - const folder = normalizeFolderPath(body?.path) - const newName = isValidName(body?.newName, 80) - if (!folder || !newName) { - return NextResponse.json({ error: 'Invalid folder or name' }, { status: 400 }) - } - if (PROTECTED_FOLDERS.has(folder.path)) { - return NextResponse.json( - { error: `"${folder.segments[folder.segments.length - 1]}" is a protected folder and cannot be renamed` }, - { status: 400 }, - ) - } - const row = await db.folder.findUnique({ where: { path: folder.path } }) - if (!row) { - return NextResponse.json({ error: 'Folder not found' }, { status: 404 }) - } - const newPath = `${parentOf(folder.segments)}/${newName}` - const clash = await db.folder.findUnique({ where: { path: newPath } }) - if (clash) { - return NextResponse.json({ error: `"${newName}" already exists there` }, { status: 409 }) - } - - const result = await db.$transaction(async (tx) => { - const folders = await tx.folder.findMany({ where: subtreeFolderFilter(folder.path) }) - const presets = await tx.preset.findMany({ - where: subtreeFilter(folder.path), - select: { id: true, folderPath: true }, - }) - for (const f of folders) { - const target = f.path === folder.path ? newPath : newPath + f.path.slice(folder.path.length) - await tx.folder.update({ where: { path: f.path }, data: { path: target } }) - } - for (const p of presets) { - await tx.preset.update({ - where: { id: p.id }, - data: { folderPath: newPath + p.folderPath.slice(folder.path.length) }, - }) - } - return { folders: folders.length, presets: presets.length } - }) - - return NextResponse.json({ renamed: folder.path, to: newPath, ...result }) - } catch (err) { - if ((err as { code?: string })?.code === 'P2002') { - return NextResponse.json({ error: 'A folder with that name already exists' }, { status: 409 }) - } - console.error('PATCH /api/folders failed:', err) - return NextResponse.json({ error: 'Failed to rename folder' }, { status: 500 }) - } +export async function PUT(_req: NextRequest) { + return NextResponse.json({ error: READ_ONLY_MSG }, { status: 403 }) } -/** Delete a folder subtree. Query: ?path=root/a/b */ -export async function DELETE(req: NextRequest) { - try { - const raw = new URL(req.url).searchParams.get('path') - const folder = normalizeFolderPath(raw) - if (!folder) { - return NextResponse.json({ error: 'Invalid folder path' }, { status: 400 }) - } - if (PROTECTED_FOLDERS.has(folder.path)) { - return NextResponse.json( - { error: `"${folder.segments[folder.segments.length - 1]}" is a protected folder and cannot be deleted` }, - { status: 400 }, - ) - } - const row = await db.folder.findUnique({ where: { path: folder.path } }) - if (!row) { - return NextResponse.json({ error: 'Folder not found' }, { status: 404 }) - } - const deletedPresets = await db.preset.deleteMany({ where: subtreeFilter(folder.path) }) - const deletedFolders = await db.folder.deleteMany({ where: subtreeFolderFilter(folder.path) }) - return NextResponse.json({ - deleted: true, - folders: deletedFolders.count, - presets: deletedPresets.count, - }) - } catch (err) { - console.error('DELETE /api/folders failed:', err) - return NextResponse.json({ error: 'Failed to delete folder' }, { status: 500 }) - } +export async function PATCH(_req: NextRequest) { + return NextResponse.json({ error: READ_ONLY_MSG }, { status: 403 }) +} + +export async function DELETE(_req: NextRequest) { + return NextResponse.json({ error: READ_ONLY_MSG }, { status: 403 }) } diff --git a/src/app/api/presets/[id]/route.ts b/src/app/api/presets/[id]/route.ts index 80a8fd3..2b3f499 100755 --- a/src/app/api/presets/[id]/route.ts +++ b/src/app/api/presets/[id]/route.ts @@ -1,61 +1,22 @@ import { NextRequest, NextResponse } from 'next/server' -import { db } from '@/lib/db' -import { isValidName } from '@/lib/server/preset-tree' export const dynamic = 'force-dynamic' -/** Update a preset (content, author, description, or rename within its folder). */ -export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const { id } = await params - const body = await req.json() - const preset = await db.preset.findUnique({ where: { id } }) - if (!preset) { - return NextResponse.json({ error: 'Preset not found' }, { status: 404 }) - } +const READ_ONLY_MSG = + 'The server preset library is read-only. Your changes are saved in your browser under "user created".' - const data: Record = {} - 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 }) - } +/** + * READ-ONLY library (v6.2): the embedded presets are shared by every visitor + * and can never be modified through the API. Editing happens on a visitor's + * own copy in their browser (localStorage, merged into the tree client-side). + */ +export async function PUT(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + void params + return NextResponse.json({ error: READ_ONLY_MSG }, { status: 403 }) } -/** Delete a preset (any preset — the tree is fully user-manageable). */ +/** Same policy — embedded presets cannot be deleted through the API. */ export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { - try { - const { id } = await params - 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 }) - } + void params + return NextResponse.json({ error: READ_ONLY_MSG }, { status: 403 }) } diff --git a/src/app/api/presets/import/route.ts b/src/app/api/presets/import/route.ts index b8e05a9..973f534 100755 --- a/src/app/api/presets/import/route.ts +++ b/src/app/api/presets/import/route.ts @@ -1,125 +1,18 @@ import { NextRequest, NextResponse } from 'next/server' -import JSZip from 'jszip' -import { db } from '@/lib/db' -import { ensureFolderRows, isValidName, normalizeFolderPath } from '@/lib/server/preset-tree' export const dynamic = 'force-dynamic' -const MAX_ZIP_BYTES = 50 * 1024 * 1024 - -interface ImportEntry { - folderPath: string - name: string - content: string -} - /** - * Import a .zip of presets, preserving its directory tree. Entries stored - * under "root/…" land at exactly that path; anything else is placed inside - * the target folder selected in the UI. Only .ava (and .txt) script files - * are imported — other files are counted and skipped. - * - * POST /api/presets/import (multipart/form-data: file, targetPath) + * READ-ONLY library (v6.2): zips are unpacked IN THE VISITOR'S BROWSER and + * land only in that visitor's "user created" area (localStorage). Nothing is + * ever uploaded to the server, so an import can never affect other visitors. */ -export async function POST(req: NextRequest) { - try { - const form = await req.formData() - const file = form.get('file') - if (!(file instanceof File)) { - return NextResponse.json({ error: 'Attach a .zip file to import' }, { status: 400 }) - } - if (file.size > MAX_ZIP_BYTES) { - return NextResponse.json({ error: 'That zip is too large (50 MB limit)' }, { status: 413 }) - } - const target = normalizeFolderPath(form.get('targetPath') ?? 'root/user created') - if (!target) { - return NextResponse.json({ error: 'Invalid target folder' }, { status: 400 }) - } - - const zip = await JSZip.loadAsync(await file.arrayBuffer()) - - const entries: ImportEntry[] = [] - let skippedOther = 0 - for (const entry of Object.values(zip.files)) { - if (entry.dir) continue - const rawPath = entry.name.replace(/\\/g, '/') - if (rawPath.includes('__MACOSX') || rawPath.split('/').some((s) => s.startsWith('.'))) continue - if (!/\.(ava|txt)$/i.test(rawPath)) { - skippedOther++ - continue - } - const segs = rawPath - .split('/') - .map((s) => s.trim()) - .filter((s) => s.length > 0 && s !== '.' && s !== '..') - if (segs.length === 0) continue - const fileName = segs[segs.length - 1] - const name = isValidName(fileName.replace(/\.(ava|txt)$/i, '')) - if (!name) { - skippedOther++ - continue - } - const dirSegs = segs.slice(0, -1) - const joined = dirSegs.length > 0 ? dirSegs.join('/') : null - const folderPath = joined - ? joined === 'root' || joined.startsWith('root/') - ? joined - : `${target.path}/${joined}` - : target.path - const norm = normalizeFolderPath(folderPath) - if (!norm) { - skippedOther++ - continue - } - const content = await entry.async('string') - entries.push({ folderPath: norm.path, name, content }) - } - - if (entries.length === 0) { - return NextResponse.json( - { error: 'No .ava preset files found in that zip' }, - { status: 400 }, - ) - } - - const foldersCreated = await ensureFolderRows([...new Set(entries.map((e) => e.folderPath))]) - - let imported = 0 - let skipped = 0 - for (const e of entries) { - const clash = await db.preset.findFirst({ - where: { folderPath: e.folderPath, name: e.name }, - select: { id: true }, - }) - if (clash) { - skipped++ - continue - } - const author = - /^author:\s*(.+)$/m - .exec(e.content)?.[1] - ?.replace(/^["']|["']$/g, '') - .trim() - .slice(0, 120) ?? '' - await db.preset.create({ - data: { - name: e.name, - content: e.content, - author, - folderPath: e.folderPath, - isBuiltIn: false, - }, - }) - imported++ - } - - return NextResponse.json({ imported, skipped, skippedOther, foldersCreated }) - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - if (message.includes('corrupted') || message.includes('zip')) { - return NextResponse.json({ error: 'That file is not a valid zip archive' }, { status: 400 }) - } - console.error('POST /api/presets/import failed:', err) - return NextResponse.json({ error: 'Failed to import presets' }, { status: 500 }) - } +export async function POST(_req: NextRequest) { + return NextResponse.json( + { + error: + 'Server-side import is disabled. Zips are imported in your browser into "user created" — nothing is uploaded.', + }, + { status: 403 }, + ) } diff --git a/src/app/api/presets/route.ts b/src/app/api/presets/route.ts index 9087309..eeae10e 100755 --- a/src/app/api/presets/route.ts +++ b/src/app/api/presets/route.ts @@ -2,15 +2,16 @@ import { NextRequest, NextResponse } from 'next/server' import { db } from '@/lib/db' import { BUILTIN_PRESETS } from '@/lib/ava/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' /** The structural backbone of the tree, created on first run. */ const BASE_FOLDERS = [ - 'root', - 'root/embedded', - 'root/user created', + ROOT, + EMBEDDED_ROOT, + USER_ROOT, 'root/embedded/Synth-C', 'root/embedded/Amp Picks', 'root/embedded/Dynamics', @@ -18,27 +19,53 @@ const BASE_FOLDERS = [ ] /** - * Version-gated seed: folder backbone + the full 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). + * Version-gated seed + INTEGRITY SELF-HEAL for the embedded library. * - * The embedded subtree (root/embedded/**) refreshes automatically whenever - * EMBEDDED_SEED_VERSION changes (i.e. the library was regenerated) — user - * presets outside root/embedded are never touched. + * The server library is the single source of truth for what every visitor + * sees, so GET verifies it on every request: + * 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 { + 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 { const settings = await db.settings.findUnique({ where: { id: 'main' }, 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 { await ensureFolderRows(BASE_FOLDERS) // refresh the embedded library subtree (+ legacy stray built-ins) await db.preset.deleteMany({ where: { OR: [ - { folderPath: 'root/embedded' }, + { folderPath: EMBEDDED_ROOT }, { folderPath: { startsWith: 'root/embedded/' } }, { isBuiltIn: true }, ], @@ -80,12 +107,52 @@ async function ensureSeeded(): Promise { } } +/** + * 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 { + 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() { try { await ensureSeeded() - const [presets, folders] = await Promise.all([ + await purgeVisitorJunk() + const [presets, backbone, embeddedSubfolders] = await Promise.all([ db.preset.findMany({ 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: { id: true, name: true, @@ -99,50 +166,41 @@ export async function GET() { 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) { console.error('GET /api/presets failed:', err) return NextResponse.json({ error: 'Failed to load presets' }, { status: 500 }) } } -export async function POST(req: NextRequest) { - try { - const body = await req.json() - const name = isValidName(body?.name) - const content = typeof body?.content === 'string' ? body.content : '' - const author = typeof body?.author === 'string' ? body.author.trim().slice(0, 120) : '' - const description = typeof body?.description === 'string' ? body.description.trim().slice(0, 500) : '' - const folder = normalizeFolderPath(body?.folderPath ?? 'root/user created') - if (!name || !content) { - return NextResponse.json({ error: 'name and content are required' }, { status: 400 }) - } - if (!folder) { - return NextResponse.json({ error: 'Invalid folder path' }, { status: 400 }) - } - const folderRow = await db.folder.findUnique({ where: { path: folder.path } }) - if (!folderRow) { - // tolerate missing ancestor rows (e.g. very old data) by creating them - await ensureFolderRows([folder.path]) - } - const clash = await db.preset.findFirst({ - where: { folderPath: folder.path, name }, - select: { id: true }, - }) - if (clash) { - return NextResponse.json( - { error: `A preset named "${name}" already exists in ${folder.path}` }, - { status: 409 }, - ) - } - 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 }) - } +/** + * READ-ONLY library (v6.2): preset creation happens exclusively in the + * visitor's browser ("user created" area, localStorage). The server accepts + * no writes, so nobody can save anything other visitors would see. + */ +export async function POST(_req: NextRequest) { + return NextResponse.json( + { + error: + 'The server preset library is read-only. Your presets are saved in your browser under "user created".', + }, + { status: 403 }, + ) } diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts deleted file mode 100755 index 60608b9..0000000 --- a/src/app/api/settings/route.ts +++ /dev/null @@ -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 = {} - 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 }) - } -} diff --git a/src/components/ava/detect-tab.tsx b/src/components/ava/detect-tab.tsx index b5ac940..352d4f3 100755 --- a/src/components/ava/detect-tab.tsx +++ b/src/components/ava/detect-tab.tsx @@ -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 -// (localStorage instantly, /api/settings debounced). +// to this browser's localStorage. 'use client' diff --git a/src/components/ava/presets-tab.tsx b/src/components/ava/presets-tab.tsx index 281af91..2f7e036 100755 --- a/src/components/ava/presets-tab.tsx +++ b/src/components/ava/presets-tab.tsx @@ -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 -// only), rows select, double-clicking a preset runs it, the running preset -// is flagged emerald, and originals download straight from the row. -// Right column: context-sensitive management (load / create / rename / -// delete / export .zip / import .zip) plus the club-mode randomizer — the -// timer itself lives in the store, this tab only wires the controls. +// The tree merges two worlds: +// • root/embedded — the shipped library, READ-ONLY for every visitor (lock +// badge in the tree; the server refuses all writes). Selecting an embedded +// preset offers "Copy to my presets" instead of rename/delete. +// • root/user created — this visitor's OWN presets, stored in their browser +// (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' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ChevronRight, + CopyPlus, Download, FilePlus2, FileText, Folder, FolderOpen, FolderPlus, + Lock, Pencil, Play, Trash2, Upload, + User, } from 'lucide-react' import { AlertDialog, @@ -47,14 +56,22 @@ import { import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' 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 { thinScrollbar } from '@/components/ava/shared' import { useToast } from '@/hooks/use-toast' import { cn } from '@/lib/utils' - -/** The tree backbone — the backend refuses to rename or delete these. */ -const PROTECTED_FOLDERS = new Set(['root', 'root/embedded', 'root/user created']) +import { ROOT, USER_ROOT, isEmbeddedPath, isUserAreaPath } from '@/lib/preset-paths' +import { + 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. */ function defaultOpen(path: string): boolean { @@ -171,6 +188,7 @@ function useLoadPreset() { function TreeFolder({ node }: { node: FolderNode }) { const isOpen = useAvaStore((s) => s.expandedFolders[node.path] ?? defaultOpen(node.path)) const selected = useAvaStore((s) => s.selectedFolder === node.path) + const readOnly = node.path === ROOT || isEmbeddedPath(node.path) return (
@@ -199,7 +217,7 @@ function TreeFolder({ node }: { node: FolderNode }) {
@@ -247,6 +279,7 @@ function TreePreset({ preset, depth }: { preset: PresetItem; depth: number }) { const loadPreset = useLoadPreset() const selected = useAvaStore((s) => s.selectedPresetId === preset.id) const running = useAvaStore((s) => s.activePresetId === preset.id) + const own = presetIsLocal(preset.id) return (
useAvaStore.getState().selectPreset(preset.id)} 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" > {running ? ( @@ -286,6 +319,15 @@ function TreePreset({ preset, depth }: { preset: PresetItem; depth: number }) { ≈ )} + {own && ( + + + + )} {preset.originalFile && ( void submitLabel: string 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 }) { const submit = async () => { @@ -424,7 +466,7 @@ export function PresetsTab() { const [delayError, setDelayError] = useState(false) const importInputRef = useRef(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(() => { void refreshPresets() }, []) @@ -439,7 +481,12 @@ export function PresetsTab() { () => presets.find((p) => p.id === selectedPresetId) ?? null, [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 folderPrefix = `${selectedFolder}/` const folderPresetCount = presets.filter( @@ -454,46 +501,35 @@ export function PresetsTab() { 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 => { setBusy(true) try { - const res = await fetch('/api/presets', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, content: STARTER_TEMPLATE, folderPath: selectedFolder }), + const res = createUserPreset({ + name, + content: STARTER_TEMPLATE, + 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 } - await refreshPresets() + applyUserPresets() 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) applyScriptText(STARTER_TEMPLATE) - st.setActivePresetId(data.preset.id) - st.selectPreset(data.preset.id) + st.setActivePresetId(res.preset.id) + st.selectPreset(res.preset.id) st.setTab('script') toast({ 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 - } catch { - toast({ - title: 'Preset not created', - description: 'Network error while creating the preset.', - variant: 'destructive', - }) - return false } finally { setBusy(false) } @@ -503,33 +539,17 @@ export function PresetsTab() { const path = `${selectedFolder}/${name}` setBusy(true) try { - const res = await fetch('/api/folders', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - 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', - }) + const res = createUserFolder(path) + if (!res.ok) { + toast({ title: 'Folder not created', description: res.error, variant: 'destructive' }) return false } - await refreshPresets() + applyUserPresets() const st = useAvaStore.getState() st.toggleFolder(selectedFolder, true) - st.selectFolder(data.folder.path) - toast({ title: 'Folder created', description: `"${name}" added under ${selectedFolder}.` }) + st.selectFolder(res.path) + toast({ title: 'Folder created', description: `"${name}" added under ${selectedFolder} (this browser).` }) return true - } catch { - toast({ - title: 'Folder not created', - description: 'Network error while creating the folder.', - variant: 'destructive', - }) - return false } finally { setBusy(false) } @@ -539,71 +559,46 @@ export function PresetsTab() { setBusy(true) try { if (selectedPreset) { - const res = await fetch(`/api/presets/${selectedPreset.id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - 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', - }) + const res = renameUserPreset(selectedPreset.id, name) + if (!res.ok) { + toast({ title: 'Rename failed', description: res.error, variant: 'destructive' }) return false } - await refreshPresets() + applyUserPresets() const st = useAvaStore.getState() if (st.editTarget?.id === selectedPreset.id) { st.setEditTarget({ - id: data.preset.id, - name: data.preset.name, - folderPath: data.preset.folderPath, + id: res.preset.id, + name: res.preset.name, + folderPath: res.preset.folderPath, }) } toast({ title: 'Preset renamed', - description: `"${selectedPreset.name}" is now "${data.preset.name}".`, + description: `"${selectedPreset.name}" is now "${res.preset.name}".`, }) return true } - // folder rename — everything below moves with it - const res = await fetch('/api/folders', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - 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', - }) + // folder rename — everything below moves with it (browser-side) + const res = renameUserFolder(selectedFolder, name) + if (!res.ok) { + toast({ title: 'Rename failed', description: res.error, variant: 'destructive' }) return false } - await refreshPresets() + applyUserPresets() const st = useAvaStore.getState() - st.selectFolder(data.to) + st.selectFolder(res.to) if ( st.editTarget && (st.editTarget.folderPath === selectedFolder || st.editTarget.folderPath.startsWith(folderPrefix)) ) { st.setEditTarget({ ...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 - } catch { - toast({ - title: 'Rename failed', - description: 'Network error while renaming.', - variant: 'destructive', - }) - return false } finally { setBusy(false) } @@ -639,31 +634,17 @@ export function PresetsTab() { setBusy(true) try { const isFolder = deleteTarget.kind === 'folder' - const res = await fetch( - 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 + const res = isFolder ? deleteUserFolder(deleteTarget.path) : deleteUserPreset(deleteTarget.path) if (!res.ok) { - toast({ - title: 'Delete failed', - description: data?.error ?? `Server responded ${res.status}.`, - variant: 'destructive', - }) + toast({ title: 'Delete failed', description: res.error, variant: 'destructive' }) return } - await refreshPresets() + applyUserPresets() toast({ title: isFolder ? 'Folder deleted' : 'Preset deleted', description: isFolder - ? `"${deleteTarget.name}" and everything inside it removed.` - : `"${deleteTarget.name}" removed.`, - }) - } catch { - toast({ - title: 'Delete failed', - description: 'Network error while deleting.', - variant: 'destructive', + ? `"${deleteTarget.name}" and everything inside it removed from this browser.` + : `"${deleteTarget.name}" removed from this browser.`, }) } finally { setBusy(false) @@ -671,36 +652,71 @@ export function PresetsTab() { } } - const importZip = async (file: File) => { - setBusy(true) - try { - const form = new FormData() - form.append('file', file) - form.append('targetPath', selectedFolder) - const res = await fetch('/api/presets/import', { method: 'POST', body: form }) - const data = (await res.json().catch(() => null)) as - | { imported?: number; skipped?: number; foldersCreated?: number; error?: string } - | null - if (!res.ok || !data) { + /** Copy an embedded preset into this visitor's browser library. */ + const copyToMyPresets = (preset: PresetItem) => { + const candidates = [preset.name, `${preset.name} (copy)`, `${preset.name} (copy 2)`, `${preset.name} (copy 3)`] + for (const name of candidates) { + const res = createUserPreset({ + name, + content: preset.content, + folderPath: USER_ROOT, + author: preset.author, + description: preset.description, + }) + if (res.ok) { + applyUserPresets() + const st = useAvaStore.getState() + st.toggleFolder(USER_ROOT, true) + st.selectPreset(res.preset.id) toast({ - title: 'Import failed', - description: data?.error ?? `Server responded ${res.status}.`, - variant: 'destructive', + title: 'Copied to your presets', + description: `"${res.preset.name}" is now yours to edit — stored in this browser only.`, }) return } - await refreshPresets() - useAvaStore.getState().toggleFolder(selectedFolder, true) - let summary = `Imported ${data.imported ?? 0} presets` - if ((data.skipped ?? 0) > 0) summary += `, ${data.skipped} duplicates skipped` - if ((data.foldersCreated ?? 0) > 0) summary += ` — ${data.foldersCreated} folders created` + if (!res.error.includes('already exists')) { + toast({ title: 'Copy failed', description: res.error, variant: 'destructive' }) + return + } + } + toast({ + title: 'Copy failed', + description: 'A preset with that name already exists — rename it in "user created" first.', + 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: 'Network error while importing.', - variant: 'destructive', - }) + toast({ title: 'Import failed', description: 'Could not read that zip.', variant: 'destructive' }) } finally { setBusy(false) } @@ -733,7 +749,7 @@ export function PresetsTab() { {presets.length} presets ·{' '} {folders.length} folders

-

double-click a preset to run it

+

double-click a preset to run it

@@ -780,6 +796,14 @@ export function PresetsTab() {

)}
+ {(folderReadOnly || (selectedPreset && !presetOwn)) && ( + + read-only + + )}
@@ -793,24 +817,72 @@ export function PresetsTab() { > Load Preset + {presetOwn ? ( +
+ + +
+ ) : ( + <> + +

+ Embedded presets are read-only — identical for every visitor. Copy it to make it yours + (stored in this browser only). +

+ + )} + + ) : folderReadOnly ? ( + <> +

+ The embedded library is read-only — the same for every + visitor, safe from edits, renames and deletion. Your own presets live under{' '} + user created, stored in this browser. +

@@ -820,7 +892,7 @@ export function PresetsTab() { size="sm" variant="outline" 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" > Create Preset @@ -829,7 +901,7 @@ export function PresetsTab() { size="sm" variant="outline" 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" > New Subfolder @@ -840,7 +912,7 @@ export function PresetsTab() { variant="outline" disabled={folderProtected} 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" > Rename @@ -850,7 +922,7 @@ export function PresetsTab() { variant="outline" disabled={folderProtected} 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" > Delete @@ -860,13 +932,12 @@ export function PresetsTab() {