eldouma-graphics / src /components /NewProjectModal.tsx
Moeeldouma's picture
Deploy Eldouma Graphics — Docker Space, bring-your-own-key demo
39d98a0 verified
Raw
History Blame Contribute Delete
16.8 kB
import { useEffect, useRef, useState } from "react";
import { X, Sparkles, Upload, Wand2, Loader2, Film, FolderOpen, Trash2, Mic, Type, Clapperboard, ClipboardPaste } from "lucide-react";
import { useStore, type ProjectManifest } from "../store";
import { scriptToWords } from "../lib/script";
import { ModelSwitcher } from "./ModelSwitcher";
import { aiHeaders } from "../lib/aiKeys";
const LENGTHS = ["~30 sec", "~1 minute", "~2 minutes"];
const TONES = ["Friendly", "Authoritative", "Cinematic", "Energetic"];
type Recent = { id: string; name: string; source: string; updatedAt?: string; words: number; assets: number };
const SOURCE_META: Record<string, { icon: typeof Type; label: string }> = {
script: { icon: Type, label: "AI script" },
import: { icon: Mic, label: "Imported" },
episode: { icon: Clapperboard, label: "Demo" },
};
export function NewProjectModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const loadProject = useStore((s) => s.loadProject);
const setView = useStore((s) => s.setView);
const model = useStore((s) => s.model);
const currentId = useStore((s) => s.projectId);
const [mode, setMode] = useState<"script" | "paste" | "import" | "recent">("script");
const [title, setTitle] = useState(""); // every project needs a title
const [pasted, setPasted] = useState("");
const [prompt, setPrompt] = useState("");
const [length, setLength] = useState(LENGTHS[1]);
const [tone, setTone] = useState(TONES[0]);
const [script, setScript] = useState("");
const [gen, setGen] = useState(false);
const [autoBuild, setAutoBuild] = useState(true); // kick off the AI build right after creating (the <60s "wow")
const [importing, setImporting] = useState("");
const [recents, setRecents] = useState<Recent[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const refreshRecents = () => fetch("/api/projects").then((r) => r.json()).then((d) => setRecents(d.projects || [])).catch(() => {});
useEffect(() => { if (open) refreshRecents(); }, [open]);
if (!open) return null;
async function createAndLoad(body: Record<string, unknown>, buildPrompt?: string) {
const res = await fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
const m: ProjectManifest = await res.json();
if (!res.ok || !m.id) return; // don't enter the editor with a broken (id-less) project
loadProject(m);
// hand the brief straight to the agent so the build starts on first load — no second intake form
if (buildPrompt) useStore.getState().requestAgent(buildPrompt);
onClose();
}
async function generate() {
if (!title.trim() && prompt.trim()) setTitle(prompt.trim().split(/\s+/).slice(0, 7).join(" ")); // suggest a title from the brief
setGen(true);
try {
const res = await fetch("/api/script", { method: "POST", headers: { "Content-Type": "application/json", ...aiHeaders() }, body: JSON.stringify({ prompt, model, length, tone }) });
const d = await res.json();
setScript(d.script || "⚠️ " + (d.error || "no script returned"));
} catch (e) { setScript("⚠️ " + e); }
setGen(false);
}
function useScript() {
if (!title.trim() || !script.trim() || script.startsWith("⚠️")) return;
const words = scriptToWords(script).map((w) => ({ text: w.text, startMs: w.startMs, endMs: w.endMs }));
const build = autoBuild ? `Build this video from the script. Length: ${length}. Tone: ${tone}. Propose a section outline with treatments, then build every section in order — pick a design style and add an art-directed motion-graphics scene for each, placed on the beat it illustrates. Keep going until every section is done, then give a one-line wrap-up.` : undefined;
createAndLoad({ name: title.trim(), words, audioSrc: "", source: "script" }, build);
}
function usePasted() {
if (!title.trim() || !pasted.trim()) return;
const words = scriptToWords(pasted).map((w) => ({ text: w.text, startMs: w.startMs, endMs: w.endMs }));
const build = autoBuild ? `Build this video from the script. Propose a section outline with treatments, then build every section in order with art-directed motion-graphics scenes placed on the beat they illustrate. Keep going until every section is done, then give a one-line wrap-up.` : undefined;
createAndLoad({ name: title.trim(), words, audioSrc: "", source: "script" }, build);
}
async function importFile(files: FileList | null) {
const f = files?.[0];
if (!f) return;
let createdId: string | null = null;
setImporting("Creating project…");
try {
const proj: ProjectManifest = await fetch("/api/projects", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: title.trim() || f.name.replace(/\.[^.]+$/, ""), source: "import" }) }).then((r) => r.json());
createdId = proj.id;
setImporting("Uploading & transcribing (Whisper)…");
const res = await fetch(`/api/projects/${proj.id}/import?name=${encodeURIComponent(f.name)}`, { method: "POST", headers: { "Content-Type": f.type || "application/octet-stream", ...aiHeaders() }, body: f });
const m = await res.json();
if (!res.ok) throw new Error(m.error || "import failed");
loadProject(m);
onClose();
} catch (e) {
if (createdId) fetch(`/api/projects/${createdId}`, { method: "DELETE" }).catch(() => {}); // don't leave an empty project behind
setImporting("⚠️ " + (e instanceof Error ? e.message : String(e)));
}
}
async function openRecent(id: string) {
const res = await fetch(`/api/projects/${id}`);
if (!res.ok) return;
loadProject(await res.json());
onClose();
}
async function deleteRecent(id: string) {
await fetch(`/api/projects/${id}`, { method: "DELETE" }).catch(() => {});
if (localStorage.getItem("ed.lastProject") === id) localStorage.removeItem("ed.lastProject");
setRecents((rs) => rs.filter((p) => p.id !== id));
if (id === currentId) { // deleted the project that's open → fall back to the demo, or home if that fails
const m = await fetch("/api/projects/demo", { method: "POST" }).then((r) => r.json()).catch(() => null);
if (m && m.id) loadProject(m); else setView("home");
}
}
const estSec = Math.round((scriptToWords(script).at(-1)?.endMs || 0) / 1000);
const wordCount = script.trim() ? script.trim().split(/\s+/).filter(Boolean).length : 0;
return (
<div className="fixed inset-0 z-50 grid place-items-center bg-black/45 p-3 sm:p-6" onClick={onClose}>
<div className="flex max-h-[88vh] w-full min-w-0 max-w-[620px] flex-col rounded-xl border border-border bg-surface-raised shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-border px-5 py-3">
<div className="flex items-center gap-2 font-semibold text-fg"><Sparkles size={16} className="text-primary" /> New Project</div>
<button onClick={onClose} className="rounded p-1 text-fg-muted hover:bg-white/5"><X size={16} /></button>
</div>
<div className="flex gap-1 overflow-x-auto px-4 pt-3 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<Tab active={mode === "script"} onClick={() => setMode("script")} icon={<Wand2 size={14} />} label="Write with AI" />
<Tab active={mode === "paste"} onClick={() => setMode("paste")} icon={<ClipboardPaste size={14} />} label="Paste script" />
<Tab active={mode === "import"} onClick={() => setMode("import")} icon={<Upload size={14} />} label="Import A/V" />
<Tab active={mode === "recent"} onClick={() => setMode("recent")} icon={<FolderOpen size={14} />} label={`Recent${recents.length ? ` (${recents.length})` : ""}`} />
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{mode !== "recent" && (
<div className="mb-4">
<div className="eyebrow mb-1">Project title <span className="text-primary">*</span></div>
<input value={title} onChange={(e) => setTitle(e.target.value)} maxLength={80} placeholder="e.g. Launch teaser — Spring 2026"
className="w-full rounded-md border border-border bg-surface px-3 py-2.5 text-[14px] font-medium outline-none focus:border-primary" />
</div>
)}
{mode === "script" ? (
<div className="space-y-3">
<div>
<div className="eyebrow mb-1">What's your video about?</div>
<textarea value={prompt} onChange={(e) => setPrompt(e.target.value)} placeholder="e.g. How AI motion graphics save editors hours — for indie creators" className="h-16 w-full resize-none rounded-md border border-border bg-surface px-3 py-2 text-[13px] outline-none focus:border-primary" />
</div>
<div className="flex flex-wrap gap-5">
<ChipRow label="Length" value={length} set={setLength} options={LENGTHS} />
<ChipRow label="Tone" value={tone} set={setTone} options={TONES} />
</div>
<div className="flex flex-wrap items-center gap-2">
<ModelSwitcher />
<span className="text-[11px] text-fg-subtle">writes the script</span>
<div className="flex-1" />
<button onClick={generate} disabled={gen} className="flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-[13px] font-semibold text-white disabled:opacity-50">
{gen ? <Loader2 size={14} className="animate-spin" /> : <Wand2 size={14} />} {script ? "Regenerate" : "Generate script"}
</button>
</div>
{(script || gen) && (
<div>
<div className="eyebrow mb-1">Script <span className="normal-case text-fg-subtle">— edit freely</span></div>
<textarea value={script} onChange={(e) => setScript(e.target.value)} placeholder={gen ? "Writing…" : ""} className="h-44 w-full resize-none rounded-md border border-border bg-surface px-3 py-2 text-[13px] leading-relaxed outline-none focus:border-primary" />
{wordCount > 0 && <div className="mt-1 text-[11px] text-fg-subtle">{wordCount} words · ~{estSec}s estimated</div>}
</div>
)}
<AutoBuildToggle checked={autoBuild} onChange={setAutoBuild} />
<button onClick={useScript} disabled={!title.trim() || !script.trim() || script.startsWith("⚠️")} className="w-full rounded-md bg-primary py-2.5 text-[14px] font-semibold text-white disabled:opacity-40">{!title.trim() ? "Add a project title first" : autoBuild ? "Create & build with AI →" : "Create project from script →"}</button>
<p className="text-[11px] text-fg-subtle">Creates a project with its own asset library + saved timeline (estimated timing). Import a voiceover — or use TTS later — to lock real timing.</p>
</div>
) : mode === "paste" ? (
<div className="space-y-3">
<div className="eyebrow mb-1">Paste your script</div>
<textarea value={pasted} onChange={(e) => setPasted(e.target.value)} placeholder="Paste a script you already wrote — it becomes your editable transcript." className="h-56 w-full resize-none rounded-md border border-border bg-surface px-3 py-2 text-[13px] leading-relaxed outline-none focus:border-primary" />
{pasted.trim() && <div className="text-[11px] text-fg-subtle">{pasted.trim().split(/\s+/).filter(Boolean).length} words · ~{Math.round((scriptToWords(pasted).at(-1)?.endMs || 0) / 1000)}s estimated</div>}
<AutoBuildToggle checked={autoBuild} onChange={setAutoBuild} />
<button onClick={usePasted} disabled={!title.trim() || !pasted.trim()} className="w-full rounded-md bg-primary py-2.5 text-[14px] font-semibold text-white disabled:opacity-40">{!title.trim() ? "Add a project title first" : autoBuild ? "Create & build with AI →" : "Create project from script →"}</button>
</div>
) : mode === "import" ? (
<div className="space-y-3">
<button onClick={() => title.trim() && inputRef.current?.click()} disabled={!title.trim()} className="flex w-full flex-col items-center gap-2 rounded-lg border border-dashed border-border py-10 text-fg-muted hover:border-primary hover:text-fg disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-border disabled:hover:text-fg-muted">
<Film size={24} />
<span className="text-[13px] font-medium">{title.trim() ? "Import your voiceover or talking-head clip" : "Add a project title first"}</span>
<span className="text-[11px] text-fg-subtle">A new project is created, auto-transcribed word-by-word, with its own asset library</span>
</button>
<input ref={inputRef} type="file" accept="audio/*,video/*" hidden onChange={(e) => importFile(e.target.files)} />
{importing && <div className="flex items-center gap-2 text-[13px] text-fg">{!importing.startsWith("⚠️") && <Loader2 size={14} className="animate-spin text-primary" />}<span className={importing.startsWith("⚠️") ? "text-destructive" : ""}>{importing}</span></div>}
</div>
) : (
<div className="space-y-2">
{recents.length === 0 && <div className="py-8 text-center text-[13px] text-fg-subtle">No saved projects yet. Create one from a script or an import — each gets its own folder and asset library.</div>}
{recents.map((p) => {
const meta = SOURCE_META[p.source] || SOURCE_META.script;
const Icon = meta.icon;
return (
<div key={p.id} className={"group flex items-center gap-3 rounded-lg border px-3 py-2.5 " + (p.id === currentId ? "border-primary bg-primary/5" : "border-border hover:border-primary/50")}>
<button onClick={() => openRecent(p.id)} className="flex min-w-0 flex-1 items-center gap-3 text-left">
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-md bg-surface text-fg-muted"><Icon size={16} /></span>
<span className="min-w-0">
<span className="block truncate text-[13px] font-medium text-fg">{p.name}{p.id === currentId ? " · open" : ""}</span>
<span className="block truncate text-[11px] text-fg-subtle">{meta.label} · {p.words} words · {p.assets} clip{p.assets === 1 ? "" : "s"}{p.updatedAt ? ` · ${new Date(p.updatedAt).toLocaleDateString()}` : ""}</span>
</span>
</button>
<button onClick={() => deleteRecent(p.id)} title="Delete project" className="shrink-0 rounded p-1.5 text-fg-subtle opacity-0 transition hover:bg-white/5 hover:text-destructive group-hover:opacity-100"><Trash2 size={14} /></button>
</div>
);
})}
</div>
)}
</div>
</div>
</div>
);
}
function AutoBuildToggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<label className="flex cursor-pointer select-none items-center gap-2 rounded-md border border-border bg-surface px-3 py-2 text-[12.5px] text-fg-muted">
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} className="h-3.5 w-3.5 accent-[#e7723b]" />
<Sparkles size={13} className="text-primary" />
<span>Build the scenes automatically with AI <span className="text-fg-subtle">— starts the moment the editor opens</span></span>
</label>
);
}
function Tab({ active, onClick, icon, label }: { active: boolean; onClick: () => void; icon: React.ReactNode; label: string }) {
return <button onClick={onClick} className={"flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-t-md border-b-2 px-3 py-2 text-[13px] " + (active ? "border-primary font-semibold text-fg" : "border-transparent text-fg-muted hover:text-fg")}>{icon} {label}</button>;
}
function ChipRow({ label, value, set, options }: { label: string; value: string; set: (v: string) => void; options: string[] }) {
return (
<div>
<div className="eyebrow mb-1">{label}</div>
<div className="flex flex-wrap gap-1.5">
{options.map((o) => <button key={o} onClick={() => set(o)} className={"rounded-full border px-2.5 py-1 text-[12px] " + (value === o ? "border-primary bg-primary/15 text-fg" : "border-border text-fg-muted hover:border-primary/50")}>{o}</button>)}
</div>
</div>
);
}