Moeeldouma's picture
Deploy Eldouma Graphics — Docker Space, bring-your-own-key demo
39d98a0 verified
Raw
History Blame Contribute Delete
18.5 kB
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 = <T,>(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<Set<string>>(() => { try { const s = JSON.parse(localStorage.getItem("ed.libCats") || "null"); return new Set<string>(Array.isArray(s) ? s : [CATEGORY_ORDER[0]]); } catch { return new Set<string>([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<TemplateDef | null>(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<string, unknown>; 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<string, unknown>, 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<HTMLDivElement>(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<HTMLElement>("[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 (
<div className="flex h-full flex-col">
<GenerateScenesModal open={genOpen} onClose={() => setGenOpen(false)} />
<TemplateGallery open={galleryOpen} onClose={() => setGalleryOpen(false)} onAdd={(id, th, ac) => addStarter(id, th, ac)} />
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-fg-subtle">Motion graphics for <span className="font-medium text-fg-muted">{projectName}</span></div>
<button onClick={() => setGenOpen(true)} className="mb-3 flex w-full items-center justify-center gap-2 rounded-md bg-primary py-2.5 text-[13px] font-semibold text-white hover:brightness-105">
<Sparkles size={15} /> Generate motion graphics
</button>
{filling > 0 && <div className="mb-3 flex items-center gap-2 rounded-md border border-primary/30 bg-primary/5 px-2.5 py-1.5 text-[12px] text-fg"><Loader2 size={13} className="animate-spin text-primary" /> Writing content from your script…</div>}
{/* 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 */}
<div className="sticky top-0 z-20 mb-3 bg-surface pb-3 pt-0.5">
<div className="overflow-hidden rounded-md border border-border bg-black shadow-sm">
{pv ? (
<AutoPreview key={pvKey} template={pv.template} props={pv.props} theme={pv.theme} accent={pv.accent} bg={pv.bg} anim={pv.anim} font={pv.font} durationInFrames={pv.durationInFrames} />
) : (
<div className="grid aspect-video place-items-center text-[11px] text-white/40">Hover a scene or template to preview · click to add</div>
)}
</div>
</div>
{/* this project's own generated scenes — multi-select with the checkbox or ⌘/shift-click */}
<div className="mb-2 flex items-center gap-2">
<div className="eyebrow">This project's scenes {generated.length ? `· ${generated.length}` : ""}</div>
<div className="flex-1" />
{selectedIds.length >= 1 ? (
<>
<button onClick={removeSelected} className="flex items-center gap-1 rounded bg-destructive px-2 py-0.5 text-[11px] font-semibold text-white hover:brightness-110"><Trash2 size={11} /> Delete {selectedIds.length}</button>
<button onClick={clearSelection} className="text-[11px] text-fg-subtle hover:text-fg">Clear</button>
</>
) : (
<button onClick={() => setGalleryOpen(true)} title="Add a scene from the template gallery" className="flex items-center gap-1 rounded border border-border px-2 py-0.5 text-[11px] font-medium text-fg-muted hover:border-primary hover:text-fg"><Plus size={11} /> Add scene</button>
)}
</div>
{generated.length === 0 ? (
<div className="mb-4 flex flex-col items-center gap-2.5 rounded-md border border-dashed border-border px-3 py-4 text-center">
<span className="grid h-9 w-9 place-items-center rounded-full bg-primary/10"><Film size={17} className="text-primary" /></span>
<div className="text-[12px] text-fg-subtle">No scenes yet — each one is auto-written from your script.</div>
<div className="flex gap-2">
<button onClick={() => setGenOpen(true)} className="flex items-center gap-1 rounded-md bg-primary px-2.5 py-1.5 text-[12px] font-semibold text-white hover:brightness-105"><Sparkles size={12} /> Generate scenes</button>
<button onClick={() => setGalleryOpen(true)} className="flex items-center gap-1 rounded-md border border-border px-2.5 py-1.5 text-[12px] font-medium text-fg-muted hover:border-primary hover:text-fg"><Plus size={12} /> Browse templates</button>
</div>
</div>
) : (
<div ref={listRef} onPointerDown={startListMarquee} className="relative mb-4 space-y-1.5">
{listBox && <div className="pointer-events-none absolute z-10 rounded-sm border border-primary bg-primary/20" style={{ left: listBox.left, top: listBox.top, width: listBox.w, height: listBox.h }} />}
{generated.map((m) => {
const sel = selectedIds.includes(m.id);
return (
<div key={m.id} data-mid={m.id} onClick={(e) => {
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<string, unknown>) || {}, 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")}>
<button onClick={(e) => { e.stopPropagation(); toggleSelect(m.id); }} title="Select" className={"grid h-4 w-4 shrink-0 place-items-center rounded border " + (sel ? "border-primary bg-primary text-white" : "border-border bg-surface text-transparent hover:border-primary")}><Check size={11} /></button>
<span className="grid h-8 w-8 shrink-0 place-items-center rounded bg-[var(--color-tl-motion)]/15 text-[var(--color-tl-motion)]"><Film size={15} /></span>
<span className="min-w-0 flex-1"><span className="block truncate text-[12px] font-medium text-fg">{m.label}</span><span className="block truncate text-[10px] text-fg-subtle">{m.template} · {(m.durationMs / 1000).toFixed(1)}s</span></span>
{/* per-row actions appear on hover so deleting/duplicating needs no drill-in */}
<ChevronRight size={14} className="shrink-0 text-fg-subtle group-hover:hidden" />
<div className="hidden shrink-0 items-center gap-0.5 group-hover:flex">
<RowAction title="Edit scene" onClick={(e) => { e.stopPropagation(); selectMotion(m.id); }}><Pencil size={13} /></RowAction>
<RowAction title="Duplicate scene" onClick={(e) => { e.stopPropagation(); duplicateMotion(m.id); }}><Copy size={13} /></RowAction>
<RowAction title="Delete scene" danger onClick={(e) => { e.stopPropagation(); removeMotion(m.id); }}><Trash2 size={13} /></RowAction>
</div>
</div>
);
})}
</div>
)}
{/* template starters, grouped by category */}
<div className="eyebrow mb-2">Add a scene <span className="normal-case text-fg-subtle">— auto-written from your script</span></div>
<button onClick={() => setGalleryOpen(true)} className="mb-2 flex w-full items-center justify-center gap-2 rounded-md border border-primary/40 bg-primary/5 py-2.5 text-[13px] font-semibold text-primary hover:bg-primary/10">
<LayoutGrid size={15} /> Browse {MG_TEMPLATE_LIST.length} templates with live previews
</button>
<div className="space-y-1.5">
{cats.map((g) => {
const open = openCats.has(g.cat);
return (
<div key={g.cat} className="overflow-hidden rounded-md border border-border/60">
<button onClick={() => toggleCat(g.cat)} className={"flex w-full items-center gap-1.5 px-2 py-1.5 text-[10px] font-semibold uppercase tracking-wider hover:text-fg " + (open ? "text-fg" : "text-fg-subtle")}>
<ChevronRight size={13} className={"shrink-0 transition " + (open ? "rotate-90 text-primary" : "")} />
<span className="flex-1 text-left">{g.cat}</span>
<span className="num rounded bg-white/5 px-1.5 py-px text-[9px] text-fg-subtle">{g.items.length}</span>
</button>
{open && (
<div className="grid grid-cols-2 gap-2 px-2 pb-2 pt-0.5">
{g.items.map((t) => (
<button key={t.id} onClick={() => addStarter(t.id)} onMouseEnter={() => setHoverTpl(t)} title={t.blurb} className="flex flex-col gap-0.5 rounded-md border border-border p-2 text-left hover:border-primary">
<span className="flex items-center gap-1 text-[12px] font-medium text-fg"><Plus size={12} className="text-primary" /> {t.label}</span>
<span className="truncate text-[10px] text-fg-subtle">{t.blurb}</span>
</button>
))}
</div>
)}
</div>
);
})}
</div>
{/* stock Chronixel scenes — demoted */}
<button onClick={() => setShowStock((v) => !v)} className="mb-2 mt-4 flex w-full items-center gap-1 text-[11px] font-semibold uppercase tracking-wider text-fg-subtle hover:text-fg">
<ChevronRight size={13} className={"transition " + (showStock ? "rotate-90" : "")} /> Stock scenes (Obsidian Orbit)
</button>
{showStock && (
<div className="grid grid-cols-2 gap-2">
{CHRONIXEL_SCENES.map((s) => (
<button key={s.id} onClick={() => addMotion(s.id, s.label, s.durationInFrames)} title={`Add "${s.label}" at the playhead`} className="group overflow-hidden rounded-md border border-border bg-surface-raised text-left transition hover:border-primary">
<div className="relative grid aspect-video place-items-center bg-[#0d0d0f] text-[10px] font-semibold tracking-wider text-white/30">CHRONIXEL</div>
<div className="px-2 py-1.5"><div className="truncate text-[11px] font-medium text-fg">{s.label}</div><div className="num text-[10px] text-fg-subtle">{Math.round(s.durationInFrames / 30)}s</div></div>
</button>
))}
</div>
)}
</div>
</div>
);
}
function RowAction({ children, title, onClick, danger }: { children: React.ReactNode; title: string; onClick: (e: React.MouseEvent) => void; danger?: boolean }) {
return (
<button title={title} onClick={onClick}
className={"grid h-6 w-6 place-items-center rounded text-fg-subtle hover:bg-white/10 " + (danger ? "hover:text-destructive" : "hover:text-fg")}>
{children}
</button>
);
}