Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 054fb5b404a6a425113f0352e0e17b54e9ade060
parent 649150050c70aa8362692c7ab621a8bff220ae18
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  8 Oct 2026 22:48:14 -0400

umtool: timeline editor, structure editors, generated-manifest banner, row notes

- project page: drag or alt+↑/↓ to re-order, row menu (duplicate, remove,
  insert clip after), Undo (restores the last auto snapshot), notes per row
- On-screen: timed notes on the built video; teaser lines, posts and
  fact-check labels edited in place
- clip bench: the clip's notes beside the verdict; edit notes re-read after a save
- the banner wherever a generated manifest can be edited

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mumtool/app/api/report/timeline/route.ts | 27++++++++++++++++++++++++---
Mumtool/components/notes/NotesProvider.tsx | 24+++++++++++++++++++++++-
Mumtool/components/projects/ClipBench.tsx | 18++++++++++++++++--
Mumtool/components/projects/ClipBenchPage.tsx | 1+
Mumtool/components/projects/OnscreenSection.tsx | 56+++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mumtool/components/projects/ReportProject.tsx | 31++++++++++++++++++++++++-------
Aumtool/components/projects/StructureEditors.tsx | 273+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/projects/TimelineEditor.tsx | 202+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/components/projects/timelineApi.ts | 39+++++++++++++++++++++++++++++++++++++++
9 files changed, 653 insertions(+), 18 deletions(-)

diff --git a/umtool/app/api/report/timeline/route.ts b/umtool/app/api/report/timeline/route.ts @@ -15,6 +15,8 @@ import { upsertPost, } from "@/lib/report/manifest.mjs"; import { resolveReport } from "@/lib/report/serve.mjs"; +import { deckOn } from "umtool-report-to-video/deck"; +import { VERDICTS, resolveFactcheck } from "umtool-report-to-video/factcheck"; export const dynamic = "force-dynamic"; @@ -23,7 +25,8 @@ export const dynamic = "force-dynamic"; // "STRUCTURE"). The window route deliberately cannot move an entry; this is // the different button it points at. // -// GET ?project=<id> the token, and the timeline's ids in order +// GET ?project=<id> the token, the timeline's ids in order, the +// teasers, the posts and the fact-check block // POST { project, token, op, ... } one op: // move { id, at?, toIndex } toIndex is the index AFTER the move // remove { id, at? } @@ -46,9 +49,27 @@ export async function GET(request: Request) { const url = new URL(request.url); const r = await resolveReport(url.searchParams.get("project") ?? ""); if ("error" in r) return Response.json({ error: r.error }, { status: r.status }); - const timeline = ((r.manifest.timeline ?? []) as { id: string; type?: string }[]).map((e) => ({ id: e.id, type: e.type ?? "entry" })); + const entries = (r.manifest.timeline ?? []) as Record<string, unknown>[]; + const timeline = entries.map((e) => ({ id: String(e.id), type: String(e.type ?? "entry") })); + // What the structure editors edit: each teaser as written, the posts, and + // the fact-check block with every default filled (the form's placeholders). + const teasers = entries + .map((e, at) => ({ e, at })) + .filter(({ e }) => e.type === "teaser") + .map(({ e, at }) => ({ at, id: String(e.id), lines: e.lines ?? [], beat: e.beat ?? null, dip: e.dip ?? null, tail: e.tail ?? null, tailWait: e.tailWait ?? null })); + const render = (r.manifest.render ?? {}) as Record<string, unknown>; return Response.json( - { timeline, generatedBy: r.manifest.generatedBy ?? null, token: await manifestToken(r.project.dir) }, + { + timeline, + teasers, + posts: Array.isArray(r.manifest.posts) ? r.manifest.posts : [], + deckOn: deckOn(render), + factcheck: (render.chrome as { factcheck?: unknown } | undefined)?.factcheck ?? null, + factcheckResolved: resolveFactcheck(render), + verdicts: VERDICTS, + generatedBy: r.manifest.generatedBy ?? null, + token: await manifestToken(r.project.dir), + }, { headers: noStore }, ); } diff --git a/umtool/components/notes/NotesProvider.tsx b/umtool/components/notes/NotesProvider.tsx @@ -1,6 +1,6 @@ "use client"; -import { createContext, useContext } from "react"; +import { createContext, useContext, useEffect } from "react"; import { notesQuery, useNotes, type NotesTarget, type UseNotes } from "@/lib/annotations/useNotes"; // ONE handle on a notes.json per page. @@ -14,8 +14,29 @@ import { notesQuery, useNotes, type NotesTarget, type UseNotes } from "@/lib/ann const Ctx = createContext<{ key: string; notes: UseNotes } | null>(null); +/** Something on the page wrote to a project's notes server-side (an edit note): re-read them. */ +export const NOTES_CHANGED = "umtool:notes-changed"; +export function announceNotesChanged(project: string) { + window.dispatchEvent(new CustomEvent(NOTES_CHANGED, { detail: { project } })); +} + +/** Re-read `notes` when the page says its project's notes changed under it. */ +export function useNotesRefresh(target: NotesTarget | null, notes: UseNotes) { + const project = target && "project" in target ? target.project : null; + const { reload } = notes; + useEffect(() => { + if (!project) return; + const on = (e: Event) => { + if ((e as CustomEvent).detail?.project === project) void reload(); + }; + window.addEventListener(NOTES_CHANGED, on); + return () => window.removeEventListener(NOTES_CHANGED, on); + }, [project, reload]); +} + export function NotesProvider({ target, children }: { target: NotesTarget; children: React.ReactNode }) { const notes = useNotes(target); + useNotesRefresh(target, notes); return <Ctx.Provider value={{ key: notesQuery(target), notes }}>{children}</Ctx.Provider>; } @@ -24,6 +45,7 @@ export function useSharedNotes(target: NotesTarget | null): UseNotes { const ctx = useContext(Ctx); const shared = !!(ctx && target && ctx.key === notesQuery(target)); const own = useNotes(shared ? null : target); + useNotesRefresh(shared ? null : target, own); return shared ? ctx!.notes : own; } diff --git a/umtool/components/projects/ClipBench.tsx b/umtool/components/projects/ClipBench.tsx @@ -1,5 +1,8 @@ "use client"; +import AnchoredNotes from "@/components/notes/AnchoredNotes"; +import GeneratedBanner from "@/components/notes/GeneratedBanner"; +import { useSharedNotes, wroteNotes } from "@/components/notes/NotesProvider"; import { useCallback, useEffect, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -256,6 +259,8 @@ export type ClipBenchData = { siblings: Sibling[]; /** Is the cut built with the on-screen panel (`render.chrome`)? */ deckOn: boolean; + /** The manifest's `generatedBy`: edits here are noted for the agent that runs it. */ + generatedBy?: string | null; }; const hms = (t: number) => { @@ -377,6 +382,9 @@ type Session = { }; export default function ClipBench({ data }: { data: ClipBenchData }) { + // The project's notes: this clip's row notes, and the edit notes a save on a + // generated manifest leaves (the save says so, and they are re-read). + const notes = useSharedNotes({ project: data.project }); const [clip, setClip] = useState<Clip>(data.clip); const [windows, setWindows] = useState<Win[]>(data.windows); const [cues, setCues] = useState<Cue[]>(data.cues); @@ -1084,6 +1092,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { }); const j = (await r.json()) as Record<string, unknown>; setBusy(null); + if (wroteNotes(j)) void notes.reload(); if (!r.ok) { setNote( j.stale @@ -1134,7 +1143,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { void refresh(); return true; }, - [data.project, clip], + [data.project, clip, notes.reload], ); /** @@ -1484,7 +1493,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { // Returned, not just stored: "did anything actually arrive" is a question // the caller has to answer before it claims a fetch worked. return j; - }, [data.project, clip.id]); + }, [data.project, clip.id, notes.reload]); // ---- fetching more -------------------------------------------------------- // @@ -1684,6 +1693,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { } setClip((prev) => fromEntry(prev, j.entry ?? {})); token.current = String(j.token ?? ""); + if (wroteNotes(j)) void notes.reload(); setCutScore(j.score ?? null); setNote(`cut to the quote (match ${(j.score ?? 0).toFixed(2)})`); }, [data.project, clip.id]); @@ -1849,6 +1859,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { data-verdict={verdict} className="flex flex-col gap-2 lg:h-full lg:min-h-0 lg:overflow-hidden" > + <GeneratedBanner generatedBy={data.generatedBy} /> {/* ---- where you are, where you can go, and what will be burned in ---- */} <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px]"> <span className="num font-mono text-[var(--color-text)]"> @@ -2942,6 +2953,9 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { })} </div> + {/* ---- notes on this clip, for whoever acts on them ---- */} + <AnchoredNotes notes={notes} pin={{ kind: "entry", entry: clip.id }} startOpen={false} /> + {/* ---- on-screen: what the panel under the footage says ---- Beside the header's fields because it is the same sitting: the words you hear are the words a title should summarise. Both diff --git a/umtool/components/projects/ClipBenchPage.tsx b/umtool/components/projects/ClipBenchPage.tsx @@ -105,6 +105,7 @@ export default async function ClipBenchPage({ // Whether the cut is built with the on-screen panel. Decides whether the // bench composes a preview of it; the fields are there either way. deckOn: deckOn(manifest.render ?? {}), + generatedBy: typeof manifest.generatedBy === "string" ? manifest.generatedBy : null, view, windows: windows.map((w: { name: string; from: number; to: number }) => ({ name: w.name, diff --git a/umtool/components/projects/OnscreenSection.tsx b/umtool/components/projects/OnscreenSection.tsx @@ -1,5 +1,10 @@ "use client"; +import GeneratedBanner from "@/components/notes/GeneratedBanner"; +import { TimedVideo } from "@/components/notes/TimedNotes"; +import StructureEditors from "./StructureEditors"; +import { MANIFEST_CHANGED } from "./timelineApi"; +import { announceNotesChanged, wroteNotes } from "@/components/notes/NotesProvider"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { badgeVariants } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; @@ -772,8 +777,11 @@ export default function OnscreenSection({ project, entries, built, + generatedBy = null, }: { project: string; + /** The manifest's `generatedBy`: its edits are noted for the agent, and the section says so. */ + generatedBy?: string | null; /** * Is the default cut's deliverable on disk? The server already knows, and * asking the video route about a file that is not there is a 404 in the @@ -821,6 +829,9 @@ export default function OnscreenSection({ const [job, setJob] = useState<JobView | null>(null); const [jobError, setJobError] = useState<string | null>(null); const [finalV, setFinalV] = useState<number | null | "none">(null); + // The built file's project-relative path (the video route's x-video): what a + // timed note on it is anchored to. + const [finalRel, setFinalRel] = useState<string | null>(null); // The switch as pressed, while its write is in flight: the box follows the // hand at once and falls back to the manifest's answer if the write fails. const [switching, setSwitching] = useState<boolean | null>(null); @@ -933,6 +944,7 @@ export default function OnscreenSection({ const loadFinal = useCallback(async () => { const r = await fetch(`/api/report/video?${q}&kind=final`, { method: "HEAD", cache: "no-store" }).catch(() => null); const m = r?.ok ? r.headers.get("x-video-mtime") : null; + setFinalRel(r?.ok ? r.headers.get("x-video") : null); setFinalV(m ? Number(m) : "none"); }, [q]); @@ -959,6 +971,28 @@ export default function OnscreenSection({ // `built` and `variant` are read once per load; loadFinal already follows the variant. }, [loadChrome, loadRows, loadFinal, recompose]); + // A structural write elsewhere on the page (the timeline, the teaser and + // posts editors) changed the manifest: re-read the tokens and the rows, + // keeping every unsaved edit here, or the next save would 409. + const formDirtyRef = useRef(formDirty); + formDirtyRef.current = formDirty; + useEffect(() => { + const on = (e: Event) => { + if ((e as CustomEvent).detail?.project !== project) return; + void (async () => { + const r = await fetch(`/api/report/chrome?project=${encodeURIComponent(project)}`, { cache: "no-store" }); + const j = (await r.json().catch(() => null)) as (ChromeDoc & { error?: string }) | null; + if (r.ok && j) { + token.current = j.token; + if (!formDirtyRef.current) setDoc(j); + } + await loadRows(true); + })(); + }; + window.addEventListener(MANIFEST_CHANGED, on); + return () => window.removeEventListener(MANIFEST_CHANGED, on); + }, [project, loadRows]); + // ---- writing ------------------------------------------------------------ const queued = useCallback(<T,>(fn: () => Promise<T>): Promise<T> => { const run = saving.current.catch(() => null).then(fn); @@ -978,6 +1012,7 @@ export default function OnscreenSection({ body: JSON.stringify({ project, chrome, token: token.current }), }); const j = (await r.json()) as Record<string, unknown>; + if (wroteNotes(j)) announceNotesChanged(project); setBusy(null); if (!r.ok) { if (j.stale) { @@ -1072,6 +1107,7 @@ export default function OnscreenSection({ body: JSON.stringify({ project, onscreen: draftMap(), token: token.current }), }); const j = (await r.json()) as Record<string, unknown>; + if (wroteNotes(j)) announceNotesChanged(project); setBusy(null); if (!r.ok) { // The drafts stay exactly as typed. A refused batch is a typo or a @@ -1114,6 +1150,7 @@ export default function OnscreenSection({ body: JSON.stringify({ project, posts: postsDraftMap(), token: token.current }), }); const j = (await r.json()) as Record<string, unknown>; + if (wroteNotes(j)) announceNotesChanged(project); setBusy(null); if (!r.ok) { if (j.stale) { @@ -1626,6 +1663,7 @@ export default function OnscreenSection({ data-onscreen={on ? "on" : "off"} className="space-y-3 rounded border border-[var(--color-line)] bg-[var(--color-panel)] p-3" > + <GeneratedBanner generatedBy={generatedBy} /> {/* ---- the switch ---- */} <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> <h2 className="micro">on-screen</h2> @@ -2050,12 +2088,12 @@ export default function OnscreenSection({ <div className="space-y-1"> <span className="micro">the built video</span> {typeof finalV === "number" ? ( - <video - data-testid="onscreen-final-video" + <TimedVideo + target={{ project }} + testId="onscreen-final-video" + file={finalRel ?? `out/${variant || "sourced"}/final.mp4`} src={`/api/report/video?${q}&kind=final&v=${finalV}`} - controls - preload="metadata" - className="aspect-video w-full rounded border border-[var(--color-line)] bg-black" + resolveProject={project} /> ) : ( <p data-testid="onscreen-no-final" className="text-[11px] text-[var(--color-dim)]"> @@ -2093,6 +2131,14 @@ export default function OnscreenSection({ <div className="mt-1.5">{postsBlock}</div> </details> )} + + {/* ---- the lists: teasers, posts, fact-check labels ---- */} + <details data-testid="structure-folded"> + <summary className="cursor-pointer text-[11px] text-[var(--color-dim)]">teasers, posts and fact-check labels</summary> + <div className="mt-1.5"> + <StructureEditors project={project} /> + </div> + </details> </section> ); } diff --git a/umtool/components/projects/ReportProject.tsx b/umtool/components/projects/ReportProject.tsx @@ -24,6 +24,9 @@ import OnscreenSection from "./OnscreenSection"; import ReportBuildChain from "./ReportBuildChain"; import SnapshotButton from "./SnapshotButton"; import TagCitedButton from "./TagCitedButton"; +import { RowControls, RowNotes, TimelineList } from "./TimelineEditor"; +import GeneratedBanner from "@/components/notes/GeneratedBanner"; +import { NotesProvider } from "@/components/notes/NotesProvider"; import { decisionsForProject } from "@/lib/projects"; import { badgeVariants, type BadgeVariants } from "@/components/ui/badge"; import { buttonVariants } from "@/components/ui/button"; @@ -257,7 +260,9 @@ export default async function ReportProject({ )} </div> + <NotesProvider target={{ project: project.id }}> <main className="deck-main flex-1 space-y-4 p-4"> + <GeneratedBanner generatedBy={typeof m.generatedBy === "string" ? m.generatedBy : null} /> {/* --- the author's own account of the cut, first ------------------ */} {readme && ( <section data-readme="" className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2"> @@ -430,6 +435,7 @@ export default async function ReportProject({ because the panel is part of the video that gets delivered. */} <OnscreenSection project={project.id} + generatedBy={typeof m.generatedBy === "string" ? m.generatedBy : null} entries={entries.map((e) => ({ id: e.id, kind: e.kind, segment: !!e.segment }))} built={!!build.built} /> @@ -459,8 +465,8 @@ export default async function ReportProject({ /> </div> )} - <ul className="space-y-1"> - {entries.map((e) => { + <TimelineList project={project.id} count={entries.length}> + {entries.map((e, at) => { // Anything that is not a CLIP renders generically. The timeline's // vocabulary is open -- one real manifest carries `scroll` and // `chart` beside its cards -- and a page that only knows two words @@ -468,17 +474,23 @@ export default async function ReportProject({ if (e.kind !== "clip") { return ( <li - key={e.id} + key={`${e.id}@${at}`} data-entry={e.id} data-kind={e.kind} - className="flex flex-wrap items-baseline gap-2 rounded border border-dashed border-[var(--color-line)] px-3 py-1.5 text-[12px]" + data-at={at} + tabIndex={0} + className="flex flex-wrap items-baseline gap-2 rounded border border-dashed border-[var(--color-line)] px-3 py-1.5 text-[12px] outline-none focus:border-[var(--color-sel)]" > + <RowControls id={e.id} at={at} /> <span className="font-mono text-[var(--color-dim)]">{e.id}</span> <Pill>{e.style ? `${e.kind} · ${e.style}` : e.kind}</Pill> <span className="text-[var(--color-text)]"> {e.heading ?? e.title ?? e.label ?? ""} </span> {e.seconds != null && <span className="num micro ml-auto">{e.seconds}s</span>} + <div className="basis-full"> + <RowNotes id={e.id} project={project.id} /> + </div> </li> ); } @@ -487,15 +499,18 @@ export default async function ReportProject({ const midSentence = e.endsSentence === false && !e.lockEnd && !e.lock; return ( <li - key={e.id} + key={`${e.id}@${at}`} data-entry={e.id} data-kind="clip" + data-at={at} + tabIndex={0} data-cached={e.cached ? "1" : "0"} data-fetched={e.fetched ? "1" : "0"} data-mid-sentence={midSentence ? "1" : "0"} - className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-1.5" + className="rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-1.5 outline-none focus:border-[var(--color-sel)]" > <div className="flex flex-wrap items-baseline gap-2 text-[12px]"> + <RowControls id={e.id} at={at} /> <span className="font-mono text-[var(--color-sel)]">{e.id}</span> <span className="num font-mono text-[11px] text-[var(--color-dim)]"> {e.video} {hms(e.start)}–{hms(e.end)} ({(e.end - e.start).toFixed(1)}s) @@ -568,10 +583,11 @@ export default async function ReportProject({ set <code className="font-mono">lock</code> if this window is deliberate </p> )} + <RowNotes id={e.id} project={project.id} /> </li> ); })} - </ul> + </TimelineList> <div className="mt-2"> <Link href={`/browse/${project.id}${showAll ? "" : "?all=1"}`} @@ -771,6 +787,7 @@ export default async function ReportProject({ </section> )} </main> + </NotesProvider> </div> ); } diff --git a/umtool/components/projects/StructureEditors.tsx b/umtool/components/projects/StructureEditors.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, 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<string, unknown> & { id: string }; +type Verdict = { label: string; color: string }; +type Doc = { + teasers: Teaser[]; + posts: Post[]; + deckOn: boolean; + factcheck: { verdicts?: Record<string, Partial<Verdict>>; stamp?: Record<string, unknown>; tally?: Record<string, unknown> } | null; + factcheckResolved: { verdicts: Record<string, Verdict>; 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<Doc | null>(null); + const [error, setError] = useState<string | null>(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<string, unknown>) => { + 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; + } + announceManifestChanged(project); + if (wroteNotes(res.json)) announceNotesChanged(project); + router.refresh(); + return true; + }; + + if (!doc) return error ? <p className="text-[11px] text-[var(--color-bad)]">{error}</p> : null; + return ( + <div data-testid="structure-editors" className="space-y-3"> + {error && ( + <p data-testid="structure-error" className="text-[11px] text-[var(--color-bad)]"> + {error} + </p> + )} + {doc.teasers.map((t) => ( + <TeaserEditor key={`${t.id}@${t.at}`} teaser={t} busy={busy} save={(patch) => run("teaser", { id: t.id, at: t.at, patch })} /> + ))} + <PostsEditor posts={doc.posts} busy={busy} save={(post) => run("post", { post })} remove={(id) => run("post-remove", { id })} /> + {doc.deckOn && <FactcheckEditor doc={doc} busy={busy} save={(factcheck) => run("factcheck", { factcheck })} />} + </div> + ); +} + +function TeaserEditor({ teaser: t, busy, save }: { teaser: Teaser; busy: boolean; save: (patch: Record<string, unknown>) => Promise<boolean> }) { + // 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<string | null>(null); + useEffect(() => { + 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 }); + }; + + return ( + <div data-teaser-editor={t.id} className="space-y-1 rounded border border-[var(--color-line)] p-2"> + <div className="micro">teaser · {t.id}</div> + <textarea data-testid={`teaser-lines-${t.id}`} rows={Math.min(8, Math.max(2, lines.split("\n").length))} value={lines} onChange={(e) => setLines(e.target.value)} className={`${input} w-full font-mono`} /> + <div className="flex flex-wrap items-center gap-2 text-[11px] text-[var(--color-dim)]"> + <label>beat <input data-testid={`teaser-beat-${t.id}`} value={beat} onChange={(e) => setBeat(e.target.value)} className={`${input} w-14`} /></label> + <label>tail <input value={tail} onChange={(e) => setTail(e.target.value)} className={`${input} w-16`} /></label> + <label>tail wait <input value={tailWait} onChange={(e) => setTailWait(e.target.value)} className={`${input} w-14`} /></label> + <label>dip fade <input value={fade} onChange={(e) => setFade(e.target.value)} className={`${input} w-14`} /></label> + <label>black <input value={black} onChange={(e) => setBlack(e.target.value)} className={`${input} w-14`} /></label> + <button type="button" data-testid={`teaser-save-${t.id}`} disabled={busy} onClick={submit} className={buttonVariants({ variant: "primary", size: "sm" })}> + Save teaser + </button> + {local && <span className="text-[var(--color-bad)]">{local}</span>} + </div> + </div> + ); +} + +const POST_FIELDS = ["platform", "author", "handle", "date", "url", "shot", "flag"] as const; + +function PostsEditor({ + posts, + busy, + save, + remove, +}: { + posts: Post[]; + busy: boolean; + save: (post: Post) => Promise<boolean>; + remove: (id: string) => Promise<boolean>; +}) { + const [adding, setAdding] = useState(false); + return ( + <div data-testid="posts-editor" className="space-y-1"> + <div className="flex items-center gap-2"> + <span className="micro">posts — {posts.length}</span> + <button type="button" data-testid="post-add" onClick={() => setAdding((a) => !a)} className="text-[11px] text-[var(--color-sel)] hover:underline"> + + post + </button> + </div> + {adding && <PostRow post={{ id: "", platform: "x" }} fresh busy={busy} save={async (p) => (await save(p)) && (setAdding(false), true)} />} + {posts.map((p) => ( + <PostRow key={p.id} post={p} busy={busy} save={save} remove={() => void remove(p.id)} /> + ))} + </div> + ); +} + +function PostRow({ post, fresh = false, busy, save, remove }: { post: Post; fresh?: boolean; busy: boolean; save: (p: Post) => Promise<boolean>; remove?: () => void }) { + const [d, setD] = useState<Record<string, string>>(() => { + const out: Record<string, string> = { id: post.id, text: String(post.text ?? "") }; + for (const k of POST_FIELDS) out[k] = post[k] == null ? "" : String(post[k]); + return out; + }); + const set = (k: string, v: string) => setD((x) => ({ ...x, [k]: v })); + return ( + <div data-post-row={post.id || "new"} className="space-y-1 rounded border border-[var(--color-line)] p-2 text-[11px]"> + <div className="flex flex-wrap items-center gap-1.5 text-[var(--color-dim)]"> + {fresh ? ( + <label>id <input data-testid="post-id" value={d.id} onChange={(e) => set("id", e.target.value)} className={`${input} w-28 font-mono`} /></label> + ) : ( + <span className="font-mono text-[var(--color-text)]">{post.id}</span> + )} + <select value={d.platform} onChange={(e) => set("platform", e.target.value)} className={`${input} text-[11px]`}> + {["x", "bluesky", "web"].map((p) => ( + <option key={p}>{p}</option> + ))} + </select> + {(["author", "handle", "date", "url", "shot", "flag"] as const).map((k) => ( + <label key={k}> + {k} <input data-testid={`post-${k}`} value={d[k]} onChange={(e) => set(k, e.target.value)} className={`${input} ${k === "url" ? "w-56" : "w-28"}`} /> + </label> + ))} + </div> + <textarea data-testid="post-text" rows={2} value={d.text} onChange={(e) => set("text", e.target.value)} className={`${input} w-full`} /> + <div className="flex gap-2"> + <button + type="button" + data-testid="post-save" + disabled={busy} + onClick={() => { + const p: Post = { id: d.id.trim(), text: d.text }; + for (const k of POST_FIELDS) p[k] = d[k]; + void save(p); + }} + className={buttonVariants({ variant: "primary", size: "sm" })} + > + {fresh ? "Add post" : "Save post"} + </button> + {remove && ( + <button type="button" data-testid="post-remove" disabled={busy} onClick={remove} className={buttonVariants({ variant: "destructive", size: "sm" })}> + Remove + </button> + )} + </div> + </div> + ); +} + +function FactcheckEditor({ doc, busy, save }: { doc: Doc; busy: boolean; save: (f: Record<string, unknown> | null) => Promise<boolean> }) { + const given = doc.factcheck ?? {}; + const [v, setV] = useState<Record<string, { label: string; color: string }>>(() => + Object.fromEntries(doc.verdicts.map((k) => [k, { label: String(given.verdicts?.[k]?.label ?? ""), color: String(given.verdicts?.[k]?.color ?? "") }])), + ); + const [seconds, setSeconds] = useState(given.stamp?.seconds == null ? "" : String(given.stamp.seconds)); + const submit = () => { + const verdicts: Record<string, Record<string, string>> = {}; + for (const [k, o] of Object.entries(v)) { + const one: Record<string, string> = {}; + if (o.label.trim()) one.label = o.label.trim(); + if (o.color.trim()) one.color = o.color.trim(); + if (Object.keys(one).length) verdicts[k] = one; + } + // Only what differs from the defaults is written, as the deck's settings are. + const out: Record<string, unknown> = { ...given }; + if (Object.keys(verdicts).length) out.verdicts = verdicts; + else delete out.verdicts; + const stamp = { ...(given.stamp ?? {}) } as Record<string, unknown>; + if (seconds.trim()) stamp.seconds = Number(seconds); + else delete stamp.seconds; + if (Object.keys(stamp).length) out.stamp = stamp; + else delete out.stamp; + void save(Object.keys(out).length ? out : null); + }; + return ( + <div data-testid="factcheck-editor" className="space-y-1 rounded border border-[var(--color-line)] p-2 text-[11px]"> + <div className="micro">fact-check labels</div> + <div className="grid grid-cols-[max-content_1fr_max-content] items-center gap-x-2 gap-y-1"> + {doc.verdicts.map((k) => { + const def = doc.factcheckResolved.verdicts[k]; + return ( + <div key={k} className="contents"> + <span className="font-mono text-[var(--color-dim)]">{k}</span> + <input data-testid={`fc-label-${k}`} placeholder={def?.label} value={v[k]?.label ?? ""} onChange={(e) => setV((x) => ({ ...x, [k]: { ...x[k], label: e.target.value } }))} className={input} /> + <span className="flex items-center gap-1"> + <input data-testid={`fc-color-${k}`} placeholder={def?.color} value={v[k]?.color ?? ""} onChange={(e) => setV((x) => ({ ...x, [k]: { ...x[k], color: e.target.value } }))} className={`${input} w-20 font-mono`} /> + <span className="inline-block h-3 w-3 rounded" style={{ background: v[k]?.color || def?.color }} /> + </span> + </div> + ); + })} + </div> + <div className="flex items-center gap-2 text-[var(--color-dim)]"> + <label>stamp seconds <input value={seconds} placeholder={String(doc.factcheckResolved.stamp.seconds)} onChange={(e) => setSeconds(e.target.value)} className={`${input} w-14`} /></label> + <button type="button" data-testid="fc-save" disabled={busy} onClick={submit} className={buttonVariants({ variant: "primary", size: "sm" })}> + Save labels + </button> + </div> + </div> + ); +} diff --git a/umtool/components/projects/TimelineEditor.tsx b/umtool/components/projects/TimelineEditor.tsx @@ -0,0 +1,202 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; +import AnchoredNotes from "@/components/notes/AnchoredNotes"; +import { announceNotesChanged, useSharedNotes, wroteNotes } from "@/components/notes/NotesProvider"; +import { buttonVariants } from "@/components/ui/button"; +import { MANIFEST_CHANGED, announceManifestChanged, refusalOf, timelineOp } from "./timelineApi"; + +// Re-ordering the cut, on the project page. +// +// The rows stay the server's (each `<li data-entry data-at>` the page always +// drew); this list adds what moves them. Drag a row by its handle, or focus it +// and press alt+↑ / alt+↓; each row's menu duplicates it, removes it, or +// inserts a clip after it (`<channel>/<video>@<start>-<end>`). Every one is a +// structural write (POST /api/report/timeline): snapshotted first, so "Undo" +// restores the cut as it was before the last burst of edits. + +type Ctx = { + project: string; + busy: boolean; + run: (op: string, args: Record<string, unknown>) => Promise<boolean>; + dragging: React.MutableRefObject<{ id: string; at: number } | null>; +}; +const TimelineCtx = createContext<Ctx | null>(null); + +const rowOf = (el: EventTarget | null) => (el instanceof Element ? (el.closest("li[data-entry][data-at]") as HTMLLIElement | null) : null); +const rowKey = (li: HTMLLIElement) => ({ id: li.dataset.entry ?? "", at: Number(li.dataset.at) }); + +export function TimelineList({ project, count, children }: { project: string; count: number; children: React.ReactNode }) { + const router = useRouter(); + const [token, setToken] = useState<string | null>(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState<string | null>(null); + const [over, setOver] = useState<number | null>(null); + const dragging = useRef<{ id: string; at: number } | null>(null); + + const loadToken = useCallback(async () => { + const r = await fetch(`/api/report/timeline?project=${encodeURIComponent(project)}`, { cache: "no-store" }); + const j = await r.json().catch(() => ({})); + if (r.ok) setToken(j.token ?? null); + }, [project]); + useEffect(() => { + void loadToken(); + const on = (e: Event) => { + if ((e as CustomEvent).detail?.project === project) void loadToken(); + }; + window.addEventListener(MANIFEST_CHANGED, on); + return () => window.removeEventListener(MANIFEST_CHANGED, on); + }, [loadToken, project]); + + const run = useCallback( + async (op: string, args: Record<string, unknown>) => { + setBusy(true); + setError(null); + const res = await timelineOp(project, token, op, args); + setBusy(false); + if (!res.ok) { + setError(refusalOf(res)); + if (res.json.stale) await loadToken(); + return false; + } + setToken(typeof res.json.token === "string" ? res.json.token : null); + announceManifestChanged(project); + if (wroteNotes(res.json)) announceNotesChanged(project); + router.refresh(); + return true; + }, + [project, token, loadToken, router], + ); + + const onKeyDown = (e: React.KeyboardEvent) => { + if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return; + if (e.target instanceof HTMLElement && /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) return; + const li = rowOf(e.target); + if (!li || busy) return; + const { id, at } = rowKey(li); + const to = at + (e.key === "ArrowUp" ? -1 : 1); + if (to < 0 || to >= count) return; + e.preventDefault(); + void run("move", { id, at, toIndex: to }).then((ok) => { + // Keep the moved row focused after the page redraws it. + if (ok) setTimeout(() => (document.querySelector(`li[data-at="${to}"]`) as HTMLElement | null)?.focus(), 400); + }); + }; + + return ( + <TimelineCtx.Provider value={{ project, busy, run, dragging }}> + <div className="mb-1.5 flex flex-wrap items-center gap-2 text-[11px]"> + <button type="button" data-testid="timeline-undo" disabled={busy} onClick={() => void run("undo", {})} className={buttonVariants({ size: "sm" })}> + Undo last edit + </button> + <span className="text-[var(--color-dim)]"> + drag ⠿ or <kbd>alt</kbd>+<kbd>↑</kbd>/<kbd>↓</kbd> to move + </span> + {busy && <span className="text-[var(--color-dim)]">saving…</span>} + {error && ( + <span data-testid="timeline-error" className="text-[var(--color-bad)]"> + {error} + </span> + )} + </div> + <ul + className="space-y-1" + data-testid="timeline-list" + data-drop-at={over ?? ""} + onKeyDown={onKeyDown} + onDragOver={(e) => { + if (!dragging.current) return; + const li = rowOf(e.target); + if (!li) return; + e.preventDefault(); + setOver(rowKey(li).at); + }} + onDragLeave={() => setOver(null)} + onDrop={(e) => { + const from = dragging.current; + const li = rowOf(e.target); + dragging.current = null; + setOver(null); + if (!from || !li) return; + e.preventDefault(); + const to = rowKey(li).at; + if (to !== from.at) void run("move", { id: from.id, at: from.at, toIndex: to }); + }} + > + {children} + </ul> + </TimelineCtx.Provider> + ); +} + +/** Inside one row: the drag handle and the row's menu. */ +export function RowControls({ id, at }: { id: string; at: number }) { + const ctx = useContext(TimelineCtx); + const [menu, setMenu] = useState(false); + const [insert, setInsert] = useState<string | null>(null); + if (!ctx) return null; + const { busy, run, dragging } = ctx; + return ( + <> + <span + draggable={!busy} + data-drag-handle={id} + title="drag to move" + onDragStart={(e) => { + dragging.current = { id, at }; + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", id); + }} + onDragEnd={() => (dragging.current = null)} + className="cursor-grab select-none text-[13px] leading-none text-[var(--color-dim)]" + > + ⠿ + </span> + <span className="relative"> + <button type="button" data-row-menu={id} onClick={() => setMenu((m) => !m)} className="px-1 text-[12px] text-[var(--color-dim)] hover:text-[var(--color-text)]" aria-label={`actions for ${id}`}> + ⋯ + </button> + {menu && ( + <span className="absolute right-0 z-10 mt-1 flex w-36 flex-col rounded border border-[var(--color-line)] bg-[var(--color-panel)] p-1 text-[11px] shadow"> + <button type="button" data-row-action="duplicate" disabled={busy} onClick={() => (setMenu(false), void run("duplicate", { id, at }))} className="px-1 py-0.5 text-left hover:bg-[var(--color-panel-2)]"> + duplicate + </button> + <button type="button" data-row-action="insert" disabled={busy} onClick={() => (setMenu(false), setInsert(""))} className="px-1 py-0.5 text-left hover:bg-[var(--color-panel-2)]"> + insert clip after + </button> + <button type="button" data-row-action="remove" disabled={busy} onClick={() => (setMenu(false), void run("remove", { id, at }))} className="px-1 py-0.5 text-left text-[var(--color-bad)] hover:bg-[var(--color-panel-2)]"> + remove + </button> + </span> + )} + </span> + {insert !== null && ( + <input + autoFocus + data-testid={`insert-after-${id}`} + value={insert} + placeholder="<channel>/<video>@<start>-<end>" + onChange={(e) => setInsert(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") setInsert(null); + if (e.key === "Enter" && insert.trim()) { + void run("insert", { afterId: id, at, entry: insert.trim() }).then((ok) => ok && setInsert(null)); + } + }} + className="w-64 rounded border border-[var(--color-line)] bg-[var(--color-ink)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--color-text)] outline-none focus:border-[var(--color-sel)]" + /> + )} + </> + ); +} + +/** Under one row: its notes (and the edit notes a generated manifest left on it). */ +export function RowNotes({ id, project }: { id: string; project: string }) { + const notes = useSharedNotes({ project }); + return ( + <div className="mt-1"> + <AnchoredNotes notes={notes} pin={{ kind: "entry", entry: id }} /> + </div> + ); +} diff --git a/umtool/components/projects/timelineApi.ts b/umtool/components/projects/timelineApi.ts @@ -0,0 +1,39 @@ +"use client"; + +// The client's side of POST /api/report/timeline, and the event every part of +// a project page listens to after the manifest changed under it. +// +// The timeline editor, the structure editors and the On-screen section each +// hold a manifest TOKEN; a structural write changes the file, so after one +// every other holder must re-read before its next save would 409. + +export const MANIFEST_CHANGED = "umtool:manifest-changed"; + +export function announceManifestChanged(project: string) { + window.dispatchEvent(new CustomEvent(MANIFEST_CHANGED, { detail: { project } })); +} + +export type TimelineResult = { + ok: boolean; + status: number; + json: Record<string, unknown> & { error?: string; errors?: string[]; stale?: boolean; token?: string }; +}; + +export async function timelineOp(project: string, token: string | null, op: string, args: Record<string, unknown> = {}): Promise<TimelineResult> { + const r = await fetch("/api/report/timeline", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ project, token, op, ...args }), + cache: "no-store", + }); + const json = (await r.json().catch(() => ({ error: `HTTP ${r.status}` }))) as TimelineResult["json"]; + return { ok: r.ok, status: r.status, json }; +} + +/** A refusal in words: the build's sentences when there are several. */ +export const refusalOf = (res: TimelineResult) => + res.json.stale + ? "the manifest changed since this page read it — reloaded; try again" + : res.json.errors?.length + ? res.json.errors.join("; ") + : String(res.json.error ?? `HTTP ${res.status}`);