"use client"; import { useState } from "react"; import { useRouter } from "next/navigation"; // Start a project that is not there yet -- of any kind the registry says can be // scaffolded. The kind select comes first; the fields under it are the ones // that kind DECLARES (`scaffold.fields` in the registry), so this file renders // from data and never branches on a kind id. `from` prefills the source field // when the dashboard was opened with ?from=/. // // It creates no cut files and no clip windows it cannot know. What it writes is // a shape to fill in, which is the whole reason it exists: the five songs and // six report videos made by hand each came out slightly different. type Brand = { id: string; label: string }; type Kind = { id: string; label: string; scaffold?: { fields: string[]; brands?: Brand[] } | null }; const FIELD_LABEL: Record = { from: "from — a sweep-report .md, a viewer share URL, or /", siteOrigin: "site origin — the archive every QR resolves to (e.g. https://jeralyzer.pages.dev)", seed: "seed the timeline from the video's digest chapters (review each)", brand: "brand — a report-to-video preset: its palette and faces, the title lockup, the header mark, an end card", }; export default function NewProjectMenu({ kinds, from = null }: { kinds: Kind[]; from?: string | null }) { const router = useRouter(); const can = kinds.filter((k) => k.scaffold); const [open, setOpen] = useState(!!from); const [kind, setKind] = useState(can[0]?.id ?? ""); const [slug, setSlug] = useState(""); const [title, setTitle] = useState(""); const [src, setSrc] = useState(from ?? ""); const [origin, setOrigin] = useState(""); const [seed, setSeed] = useState(false); const [brand, setBrand] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const fields = can.find((k) => k.id === kind)?.scaffold?.fields ?? []; const brands = can.find((k) => k.id === kind)?.scaffold?.brands ?? []; // Seeding only means something for a video ref, and the server refuses it // otherwise -- so the box is offered only when the source looks like one. const looksLikeVideo = /[?&]v=/.test(src) || /^[\w.-]+\/[\w.-]+$/.test(src.trim()); const create = async () => { setBusy(true); setError(null); try { const body: Record = { kind, slug, title }; if (fields.includes("from") && src.trim()) body.from = src.trim(); if (fields.includes("siteOrigin") && origin.trim()) body.siteOrigin = origin.trim(); if (fields.includes("seed") && seed && looksLikeVideo) body.seed = "chapters"; if (fields.includes("brand") && brand) body.brand = brand; const res = await fetch("/api/projects/new", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body), cache: "no-store", }); const j = await res.json(); if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`); router.push(j.href); router.refresh(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); setBusy(false); } }; if (!can.length) return null; if (!open) { return ( ); } const input = "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)]"; const onEnter = (e: React.KeyboardEvent) => e.key === "Enter" && slug.trim() && create(); return (
setSlug(e.target.value)} onKeyDown={onEnter} placeholder="directory name, e.g. donkey-kong" aria-label="project directory name" data-new-slug className={`w-52 font-mono ${input}`} /> setTitle(e.target.value)} onKeyDown={onEnter} placeholder="title (optional)" aria-label="project title" data-new-title className={`w-56 ${input}`} /> {fields.includes("from") && ( setSrc(e.target.value)} onKeyDown={onEnter} placeholder="from: report.md, share URL, or channel/videoId" aria-label={FIELD_LABEL.from} title={FIELD_LABEL.from} data-new-from className={`w-72 font-mono ${input}`} /> )} {fields.includes("siteOrigin") && ( setOrigin(e.target.value)} onKeyDown={onEnter} placeholder="site origin, e.g. https://…pages.dev" aria-label={FIELD_LABEL.siteOrigin} title={FIELD_LABEL.siteOrigin} data-new-origin className={`w-56 font-mono ${input}`} /> )} {fields.includes("seed") && looksLikeVideo && ( )} {fields.includes("brand") && brands.length > 0 && ( )} {error && ( {error} )}
); }