import { useRef, useState } from "react"; import { Sparkles, Plus, ChevronRight, Film, Loader2, LayoutGrid, Trash2, Check, Copy, Pencil } from "lucide-react"; import { CHRONIXEL_SCENES } from "../remotion/chronixel"; import { MG_TEMPLATE_LIST, CATEGORY_ORDER, type TemplateDef } from "../remotion/mg/templates"; import { useStore } from "../store"; import { GenerateScenesModal } from "./GenerateScenesModal"; import { TemplateGallery, AutoPreview } from "./TemplateGallery"; import { aiHeaders } from "../lib/aiKeys"; const clone = (o: T): T => JSON.parse(JSON.stringify(o)); export function Library() { const motion = useStore((s) => s.motion); const projectName = useStore((s) => s.projectName); const selectMotion = useStore((s) => s.selectMotion); const toggleSelect = useStore((s) => s.toggleSelect); const selectRange = useStore((s) => s.selectRange); const removeSelected = useStore((s) => s.removeSelected); const removeMotion = useStore((s) => s.removeMotion); const duplicateMotion = useStore((s) => s.duplicateMotion); const clearSelection = useStore((s) => s.clearSelection); const selectedIds = useStore((s) => s.selectedIds); const inspectId = useStore((s) => s.inspectId); // set when a scene is OPEN (single) → distinguishes "inspecting" from "multi-select mode" const addGeneratedScene = useStore((s) => s.addGeneratedScene); const addMotion = useStore((s) => s.addMotion); const [genOpen, setGenOpen] = useState(false); const [galleryOpen, setGalleryOpen] = useState(false); const [showStock, setShowStock] = useState(false); // collapsible template categories (persisted) — keeps the long picker compact + organized const [openCats, setOpenCats] = useState>(() => { try { const s = JSON.parse(localStorage.getItem("ed.libCats") || "null"); return new Set(Array.isArray(s) ? s : [CATEGORY_ORDER[0]]); } catch { return new Set([CATEGORY_ORDER[0]]); } }); const toggleCat = (c: string) => setOpenCats((prev) => { const n = new Set(prev); n.has(c) ? n.delete(c) : n.add(c); try { localStorage.setItem("ed.libCats", JSON.stringify([...n])); } catch { /* ignore */ } return n; }); const [filling, setFilling] = useState(0); const [hoverTpl, setHoverTpl] = useState(null); // hovering one of THIS project's scenes previews its real content + look (props/theme/accent/bg/anim/font) const [hoverScene, setHoverScene] = useState<{ id: string; template: string; props: Record; theme: string; accent: string; bg?: string; anim?: string; font?: string; durationInFrames: number } | null>(null); // what the preview box plays: a hovered project scene wins, else the hovered starter (its sample) const pv = hoverScene ?? (hoverTpl ? { id: hoverTpl.id, template: hoverTpl.id, props: hoverTpl.sample as Record, theme: "dark", accent: "#E7723B", bg: undefined, anim: undefined, font: undefined, durationInFrames: Math.max(1, Math.round(hoverTpl.defaultDurationSec * 30)) } : null); // a stable key for "which thing is hovered" — remounts AutoPreview (which seeks+plays) once per hover const pvKey = hoverScene ? "s:" + hoverScene.id : hoverTpl ? "t:" + hoverTpl.id : ""; // drag a box over the scene rows to MULTI-SELECT them (without opening the editor). Mirrors the // timeline marquee. A drag suppresses the row's click-to-open; a plain click still opens the editor. const setSelection = useStore((s) => s.setSelection); const setMarqueeing = useStore((s) => s.setMarqueeing); const listRef = useRef(null); const [listBox, setListBox] = useState<{ left: number; top: number; w: number; h: number } | null>(null); const suppressClick = useRef(false); const startListMarquee = (e: React.PointerEvent) => { if (e.button !== 0) return; if ((e.target as HTMLElement).closest("button")) return; // checkbox / row actions handle their own clicks const cont = listRef.current; if (!cont) return; const additive = e.shiftKey || e.metaKey || e.ctrlKey; const x0 = e.clientX, y0 = e.clientY; let moved = false; document.body.style.userSelect = "none"; const apply = (ev: PointerEvent) => { const x1 = ev.clientX, y1 = ev.clientY; if (!moved && (Math.abs(x1 - x0) > 4 || Math.abs(y1 - y0) > 4)) { moved = true; setMarqueeing(true); } // keep the list mounted for the rest of the drag if (!moved) return; const cr = cont.getBoundingClientRect(); setListBox({ left: Math.min(x0, x1) - cr.left, top: Math.min(y0, y1) - cr.top, w: Math.abs(x1 - x0), h: Math.abs(y1 - y0) }); const bl = Math.min(x0, x1), br = Math.max(x0, x1), bt = Math.min(y0, y1), bb = Math.max(y0, y1); const ids: string[] = []; cont.querySelectorAll("[data-mid]").forEach((el) => { const r = el.getBoundingClientRect(); if (!(r.right < bl || r.left > br || r.bottom < bt || r.top > bb)) ids.push(el.dataset.mid!); }); setSelection(ids, additive); }; const onUp = () => { window.removeEventListener("pointermove", apply); window.removeEventListener("pointerup", onUp); window.removeEventListener("pointercancel", onUp); document.body.style.userSelect = ""; setListBox(null); setMarqueeing(false); // release → the panel may now swap to the bulk-style view for the final selection if (moved) { suppressClick.current = true; setTimeout(() => (suppressClick.current = false), 0); } }; window.addEventListener("pointermove", apply); window.addEventListener("pointerup", onUp); window.addEventListener("pointercancel", onUp); }; const generated = motion.filter((m) => m.template); // "custom" (the AI-designed scene) is omitted here — it's created by asking the agent to "generate a // new scene" (design_scene), not added from the library. Dropping it empties the "More" bucket → hidden. const cats = CATEGORY_ORDER.map((c) => ({ cat: c, items: MG_TEMPLATE_LIST.filter((t) => t.category === c && t.id !== "custom") })).filter((g) => g.items.length); // add a template (with the chosen look), then auto-write its content from the script (best-effort) const addStarter = async (tplId: string, theme = "dark", accent = "#E7723B") => { const def = MG_TEMPLATE_LIST.find((t) => t.id === tplId); if (!def) return; const id = addGeneratedScene({ template: def.id, props: clone(def.sample), label: def.label, durationSec: def.defaultDurationSec, theme, accent }); selectMotion(id); const projectAtStart = useStore.getState().projectId; const words = useStore.getState().words.filter((w) => !w.deleted); if (!words.length) return; // nothing to draw content from → keep the sample setFilling((n) => n + 1); try { const transcript = words.map((w) => w.text).join(" ").slice(0, 1600); const r = await fetch("/api/fill-scene", { method: "POST", headers: { "Content-Type": "application/json", ...aiHeaders() }, body: JSON.stringify({ template: def.id, topic: projectName, transcript, lang: useStore.getState().lang }) }); const d = await r.json(); const s = useStore.getState(); // only apply if we're still on the same project AND the scene still exists (user may have switched/deleted) if (r.ok && d.props && typeof d.props === "object" && s.projectId === projectAtStart && s.motion.some((m) => m.id === id)) s.updateMotionProps(id, d.props); } catch { /* keep sample */ } setFilling((n) => Math.max(0, n - 1)); }; return (
setGenOpen(false)} /> setGalleryOpen(false)} onAdd={(id, th, ac) => addStarter(id, th, ac)} />
Motion graphics for {projectName}
{filling > 0 &&
Writing content from your script…
} {/* sticky preview "monitor" — pinned to the top while you scroll, so hovering ANY scene (below) OR template starter (further down) always plays here where you can see it */}
{pv ? ( ) : (
Hover a scene or template to preview · click to add
)}
{/* this project's own generated scenes — multi-select with the checkbox or ⌘/shift-click */}
This project's scenes {generated.length ? `· ${generated.length}` : ""}
{selectedIds.length >= 1 ? ( <> ) : ( )}
{generated.length === 0 ? (
No scenes yet — each one is auto-written from your script.
) : (
{listBox &&
} {generated.map((m) => { const sel = selectedIds.includes(m.id); return (
{ if (suppressClick.current) return; if (e.metaKey || e.ctrlKey) return toggleSelect(m.id); if (e.shiftKey) return selectRange(m.id); // once you're in selection mode (a checkbox is ticked, nothing open) a plain click // ADDS/REMOVES from the selection; otherwise it opens the scene in the inspector if (inspectId == null && selectedIds.length > 0) toggleSelect(m.id); else selectMotion(m.id); }} onMouseEnter={() => m.template && setHoverScene({ id: m.id, template: m.template, props: (m.props as Record) || {}, theme: m.theme || "dark", accent: m.accent || "#E7723B", bg: m.bg, anim: m.anim, font: m.font, durationInFrames: Math.max(1, Math.round((m.durationMs / 1000) * 30)) })} onMouseLeave={() => setHoverScene(null)} className={"group flex w-full cursor-pointer select-none items-center gap-2 rounded-md border px-2.5 py-2 text-left " + (sel ? "border-primary bg-primary/10" : "border-border hover:border-primary/50")}> {m.label}{m.template} · {(m.durationMs / 1000).toFixed(1)}s {/* per-row actions appear on hover so deleting/duplicating needs no drill-in */}
{ e.stopPropagation(); selectMotion(m.id); }}> { e.stopPropagation(); duplicateMotion(m.id); }}> { e.stopPropagation(); removeMotion(m.id); }}>
); })}
)} {/* template starters, grouped by category */}
Add a scene — auto-written from your script
{cats.map((g) => { const open = openCats.has(g.cat); return (
{open && (
{g.items.map((t) => ( ))}
)}
); })}
{/* stock Chronixel scenes — demoted */} {showStock && (
{CHRONIXEL_SCENES.map((s) => ( ))}
)}
); } function RowAction({ children, title, onClick, danger }: { children: React.ReactNode; title: string; onClick: (e: React.MouseEvent) => void; danger?: boolean }) { return ( ); }