"use client"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; import { announceNotesChanged, wroteNotes } from "@/components/notes/NotesProvider"; import { buttonVariants } from "@/components/ui/button"; import { MANIFEST_CHANGED, announceManifestChanged, refusalOf, timelineOp } from "./timelineApi"; // The parts of the cut that are lists, edited in place: each teaser's lines // and timing, the posts, and the fact-check's labels and colours. Every save // is one structural write (POST /api/report/timeline): checked by the build's // own validators, snapshotted first, and -- on a generated manifest -- noted // for the agent. type Teaser = { at: number; id: string; lines: unknown[]; beat: number | null; dip: { fade: number; black: number } | null; tail: string | null; tailWait: number | null }; type Post = Record & { id: string }; type Verdict = { label: string; color: string }; type Doc = { teasers: Teaser[]; posts: Post[]; deckOn: boolean; factcheck: { verdicts?: Record>; stamp?: Record; tally?: Record } | null; factcheckResolved: { verdicts: Record; stamp: { seconds: number; position: string }; tally: { show: boolean; position: string } }; verdicts: string[]; token: string | null; }; const input = "rounded border border-[var(--color-line)] bg-[var(--color-ink)] px-1.5 py-0.5 text-[12px] text-[var(--color-text)] outline-none focus:border-[var(--color-sel)]"; const num = (v: string) => (v.trim() === "" ? null : Number(v)); export default function StructureEditors({ project }: { project: string }) { const router = useRouter(); const [doc, setDoc] = useState(null); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const load = useCallback(async () => { const r = await fetch(`/api/report/timeline?project=${encodeURIComponent(project)}`, { cache: "no-store" }); const j = await r.json(); if (r.ok) setDoc(j as Doc); else setError(String(j.error ?? r.status)); }, [project]); useEffect(() => { void load(); const on = (e: Event) => { if ((e as CustomEvent).detail?.project === project) void load(); }; window.addEventListener(MANIFEST_CHANGED, on); return () => window.removeEventListener(MANIFEST_CHANGED, on); }, [load, project]); const run = async (op: string, args: Record) => { setBusy(true); setError(null); const res = await timelineOp(project, doc?.token ?? null, op, args); setBusy(false); if (!res.ok) { setError(refusalOf(res)); if (res.json.stale) await load(); return false; } // Adopt the new token now: the reload the announcement starts may land // after the next save is pressed. if (typeof res.json.token === "string") setDoc((d) => (d ? { ...d, token: res.json.token as string } : d)); announceManifestChanged(project); if (wroteNotes(res.json)) announceNotesChanged(project); router.refresh(); return true; }; if (!doc) return error ?

{error}

: null; return (
{error && (

{error}

)} {doc.teasers.map((t) => ( run("teaser", { id: t.id, at: t.at, patch })} /> ))} run("post", { post })} remove={(id) => run("post-remove", { id })} /> {doc.deckOn && run("factcheck", { factcheck })} />}
); } function TeaserEditor({ teaser: t, busy, save }: { teaser: Teaser; busy: boolean; save: (patch: Record) => Promise }) { // Plain lines edit as one per row; a teaser whose lines carry settings // (break, role, replace…) edits as the JSON it is, so nothing is dropped. const plain = t.lines.every((l) => typeof l === "string"); const linesText = () => (plain ? (t.lines as string[]).join("\n") : JSON.stringify(t.lines, null, 2)); const [lines, setLines] = useState(linesText); const [beat, setBeat] = useState(t.beat == null ? "" : String(t.beat)); const [tail, setTail] = useState(t.tail ?? ""); const [tailWait, setTailWait] = useState(t.tailWait == null ? "" : String(t.tailWait)); const [fade, setFade] = useState(t.dip ? String(t.dip.fade) : ""); const [black, setBlack] = useState(t.dip ? String(t.dip.black) : ""); const [local, setLocal] = useState(null); // Unsaved edits win over a reload: the re-read every structural write sets // off can land after somebody has started typing again. const dirty = useRef(false); const edit = (set: (v: T) => void) => (v: T) => { dirty.current = true; set(v); }; useEffect(() => { if (dirty.current) return; setLines(linesText()); setBeat(t.beat == null ? "" : String(t.beat)); setTail(t.tail ?? ""); setTailWait(t.tailWait == null ? "" : String(t.tailWait)); setFade(t.dip ? String(t.dip.fade) : ""); setBlack(t.dip ? String(t.dip.black) : ""); // eslint-disable-next-line react-hooks/exhaustive-deps }, [t]); const submit = () => { setLocal(null); let parsed: unknown[]; try { parsed = plain ? lines.split("\n").map((l) => l.trim()).filter(Boolean) : JSON.parse(lines); } catch { setLocal("the lines are not valid JSON"); return; } const dip = fade.trim() || black.trim() ? { fade: num(fade), black: num(black) } : null; void save({ lines: parsed, beat: num(beat), tail: tail.trim() || null, tailWait: num(tailWait), dip }).then((ok) => { if (ok) dirty.current = false; }); }; return (
teaser · {t.id}