Spaces:
Running
Running
| 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> | |
| ); | |
| } | |