"use client"; import { useEffect, useState } from "react"; import Link from "next/link"; import type { Operation, Spec, SpecProblem, SpecSound } from "@/lib/spec"; // The spec sheet, edited. // // This is the same file song/spec.mjs writes, and the same file the CLI reads -- // which is the point of it existing at all. Every field here is one line in a // small JSON document a human can open, so "point Metal Slug at the music // background" is one edit, visible from both sides. // // The OPERATIONS list is the payoff and sits at the top: it is what the declared // structures let you do. An operation that is unavailable says which structure it // wanted rather than quietly disappearing, because "why is there no trim button // on this song" is exactly the question the sheet exists to answer. type Props = { song: string; initial: Spec; problems: SpecProblem[]; operations: Operation[]; plans: string[]; trimSets: { id: string; label: string }[]; }; const field = "w-full rounded border border-[var(--color-line)] bg-[var(--color-ink)] px-1.5 py-1 text-[12px] text-[var(--color-text)] outline-none focus:border-[var(--color-sel)]"; function Row({ label, children }: { label: string; children: React.ReactNode }) { return ( ); } /** "" -> undefined, so an emptied box removes the key rather than storing "". */ const opt = (v: string) => (v.trim() ? v : undefined); const optNum = (v: string) => { const n = Number(v); return v.trim() && Number.isFinite(n) ? n : undefined; }; export default function SpecSheet({ song, initial, problems: initialProblems, operations: initialOps, plans, trimSets, }: Props) { const [spec, setSpec] = useState(initial); const [problems, setProblems] = useState(initialProblems); const [ops, setOps] = useState(initialOps); const [dirty, setDirty] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [ready, setReady] = useState(false); useEffect(() => setReady(true), []); const edit = (fn: (draft: Spec) => void) => { setSpec((prev) => { const next = structuredClone(prev); fn(next); return next; }); setDirty(true); }; const save = async () => { setSaving(true); setError(null); try { const res = await fetch("/api/browse/spec", { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ song, spec }), cache: "no-store", }); const j = await res.json(); if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`); setSpec(j.spec); setProblems(j.problems); setOps(j.operations); setDirty(false); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setSaving(false); } }; const sounds: SpecSound[] = spec.sounds ?? []; return (
what this song supports
    {ops.map((o) => (
  • {o.href && !o.blocked ? ( {o.label} ) : ( {o.label} )} {o.structure} {o.blocked && — {o.blocked}}
  • ))}
spec sheet
videos/{song}/spec.json
{problems.length > 0 && (
    {problems.map((p, i) => (
  • {p.where}: {p.message}
  • ))}
)} {error &&

{error}

}
edit((d) => void (d.title = opt(e.target.value)))} /> edit((d) => void (d.builder = opt(e.target.value)))} />
{/* background */}
background edit((d) => { const v = opt(e.target.value); if (!v) delete d.background; else d.background = { ...(d.background ?? {}), path: v }; }) } /> {spec.background && (
edit((d) => { if (d.background) d.background.dim = optNum(e.target.value); }) } /> edit((d) => { if (d.background) d.background.handoverAt = optNum(e.target.value); }) } />
)} {spec.background && ( edit((d) => { if (d.background) d.background.note = opt(e.target.value); }) } /> )}
{/* sounds */}
sounds {sounds.map((s, i) => (
edit((d) => void (d.sounds![i].id = e.target.value))} /> edit((d) => void (d.sounds![i].path = e.target.value))} /> edit((d) => void (d.sounds![i].at = optNum(e.target.value)))} /> edit((d) => void (d.sounds![i].gain = optNum(e.target.value)))} />
))}
{/* drums */}
drums edit((d) => { const v = opt(e.target.value); if (!v) delete d.drums; else d.drums = { ...(d.drums ?? {}), path: v }; }) } /> {spec.drums && (
edit((d) => void (d.drums!.shift = optNum(e.target.value)))} /> edit((d) => void (d.drums!.tempoScale = optNum(e.target.value)))} /> edit((d) => void (d.drums!.trim = optNum(e.target.value)))} />
)}
{/* hooks + voices */}
hooks
voices
); }