"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 `
` 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 (`/@-`). 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) => Promise;
dragging: React.MutableRefObject<{ id: string; at: number } | null>;
};
const TimelineCtx = createContext(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();
// The manifest's write token, in a ref as well as state: a key pressed before
// the first read came back must wait for it, not send no token at all.
const tokenRef = useRef(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState(null);
const [over, setOver] = useState(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) tokenRef.current = typeof j.token === "string" ? j.token : null;
return tokenRef.current;
}, [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) => {
setBusy(true);
setError(null);
const res = await timelineOp(project, tokenRef.current ?? (await loadToken()), op, args);
setBusy(false);
if (!res.ok) {
setError(refusalOf(res));
if (res.json.stale) await loadToken();
return false;
}
tokenRef.current = typeof res.json.token === "string" ? res.json.token : null;
announceManifestChanged(project);
if (wroteNotes(res.json)) announceNotesChanged(project);
router.refresh();
return true;
},
[project, 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 (
void run("undo", {})} className={buttonVariants({ size: "sm" })}>
Undo last edit
drag ⠿ or alt +↑ /↓ to move
{busy && saving… }
{error && (
{error}
)}
{
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}
);
}
/** 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(null);
if (!ctx) return null;
const { busy, run, dragging } = ctx;
return (
<>
{
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)]"
>
⠿
setMenu((m) => !m)} className="px-1 text-[12px] text-[var(--color-dim)] hover:text-[var(--color-text)]" aria-label={`actions for ${id}`}>
⋯
{menu && (
(setMenu(false), void run("duplicate", { id, at }))} className="px-1 py-0.5 text-left hover:bg-[var(--color-panel-2)]">
duplicate
(setMenu(false), setInsert(""))} className="px-1 py-0.5 text-left hover:bg-[var(--color-panel-2)]">
insert clip after
(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
)}
{insert !== null && (
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 (
);
}