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