import { useEffect, useMemo, useState } from "react"; import { Film, Image as ImageIcon, Video, Wand2, Plus, Save, Layers, Sparkles, Edit3, Play, ArrowLeft } from "lucide-react"; import type { Project, Scene, Shot, ShotVersion, ProjectPhase } from "../types"; interface ProjectDetailViewProps { project: Project; scenes: Scene[]; shots: Shot[]; phase: ProjectPhase; selectedSceneId: string | null; selectedShotId: string | null; onSelectScene: (id: string) => void; onSelectShot: (id: string | null) => void; onRefresh: () => Promise | void; onBack: () => void; } function mediaUrl(file: string | null | undefined): string | null { if (!file) return null; if (file.startsWith("/") || file.startsWith("http")) return file; return `/media/${file}`; } export function ProjectDetailView({ project, scenes, shots, phase, selectedSceneId, selectedShotId, onSelectScene, onSelectShot, onRefresh, onBack, }: ProjectDetailViewProps) { const [versions, setVersions] = useState([]); const [busyShotId, setBusyShotId] = useState(null); const [error, setError] = useState(null); const selectedScene = useMemo(() => scenes.find((s) => s.id === selectedSceneId) || null, [scenes, selectedSceneId]); const selectedShot = useMemo(() => shots.find((s) => s.id === selectedShotId) || null, [shots, selectedShotId]); const sceneShots = useMemo(() => shots.filter((s) => s.scene_id === selectedSceneId), [shots, selectedSceneId]); // Load versions for the focused shot useEffect(() => { if (!selectedShotId || !selectedSceneId) { setVersions([]); return; } let cancelled = false; fetch(`/api/projects/${project.id}/scenes/${selectedSceneId}/shots/${selectedShotId}/versions`) .then((r) => r.ok ? r.json() : { versions: [] }) .then((data) => { if (!cancelled) setVersions(data.versions || []); }) .catch(() => { if (!cancelled) setVersions([]); }); return () => { cancelled = true; }; }, [project.id, selectedSceneId, selectedShotId, shots]); async function patchShot(shotId: string, patch: Partial) { const shot = shots.find((s) => s.id === shotId); if (!shot) return; setBusyShotId(shotId); try { const response = await fetch(`/api/projects/${project.id}/scenes/${shot.scene_id}/shots/${shotId}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch), }); if (!response.ok) throw new Error(`Patch failed: ${response.status}`); await onRefresh(); } catch (e) { setError(e instanceof Error ? e.message : "Failed to save shot"); } finally { setBusyShotId(null); } } async function addShot() { if (!selectedSceneId) return; const next = sceneShots.length > 0 ? Math.max(...sceneShots.map((s) => s.shot_number)) + 1 : 1; try { const response = await fetch(`/api/projects/${project.id}/scenes/${selectedSceneId}/shots`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ shot_number: next, description: "" }), }); if (!response.ok) throw new Error(`Add shot failed: ${response.status}`); const created = await response.json(); await onRefresh(); onSelectShot(created.id); } catch (e) { setError(e instanceof Error ? e.message : "Failed to add shot"); } } async function generateImage(shot: Shot) { setBusyShotId(shot.id); try { const response = await fetch(`/api/projects/${project.id}/scenes/${shot.scene_id}/shots/${shot.id}/generate-image`, { method: "POST" }); if (!response.ok) throw new Error(`Generate failed: ${response.status}`); await onRefresh(); } catch (e) { setError(e instanceof Error ? e.message : "Failed to generate image"); } finally { setBusyShotId(null); } } async function animateShot(shot: Shot) { setBusyShotId(shot.id); try { const response = await fetch(`/api/projects/${project.id}/scenes/${shot.scene_id}/shots/${shot.id}/animate`, { method: "POST" }); if (!response.ok) throw new Error(`Animate failed: ${response.status}`); await onRefresh(); } catch (e) { setError(e instanceof Error ? e.message : "Failed to animate"); } finally { setBusyShotId(null); } } async function selectVersion(version: ShotVersion) { if (!selectedShot) return; try { const response = await fetch(`/api/projects/${project.id}/scenes/${selectedShot.scene_id}/shots/${selectedShot.id}/versions/${version.id}/select`, { method: "POST" }); if (!response.ok) throw new Error(`Select version failed: ${response.status}`); await onRefresh(); } catch (e) { setError(e instanceof Error ? e.message : "Failed to select version"); } } return (
{/* Top bar */}

Project

{project.title}

{project.aspect_ratio} {project.duration_seconds !== null && ( {project.duration_seconds}s )} {project.status}
{/* Main split: center | right context editor */}
{/* Center: shots in current scene */}
{selectedScene ? ( <>

Scene {selectedScene.scene_number}

{selectedScene.heading || "Untitled scene"}

{selectedScene.summary && (

{selectedScene.summary}

)}
{sceneShots.length === 0 ? (

No shots in this scene yet.

Add shots and write image prompts before generating.

) : (
{sceneShots.map((shot) => ( onSelectShot(shot.id)} onGenerateImage={() => generateImage(shot)} onAnimate={() => animateShot(shot)} /> ))}
)} ) : ( )}
{/* Bottom: shot version strip */}
{selectedShot ? `S${selectedScene?.scene_number}·shot ${selectedShot.shot_number} versions` : "Versions"} {!selectedShot && ( Select a shot to see its generated versions. )} {selectedShot && versions.length === 0 && ( No versions yet. Generate to create one. )} {versions.map((version) => { const url = mediaUrl(version.file); const isCurrent = selectedShot?.image_file === version.file || selectedShot?.video_file === version.file; return ( ); })}
{/* Right: context-aware editor — styled to match AppSidebar */}
{error && (
setError(null)}> {error}
)}
); } function PhaseChip({ phase }: { phase: ProjectPhase }) { if (phase === "outline") { return ( Outline ); } return ( Remix ); } function OutlineCenter({ project }: { project: Project }) { return (

Outline phase

No scenes in {project.title} yet. Pitch your idea to your agent and it'll draft the structure here, or add the first scene yourself.

); } interface ShotCardProps { shot: Shot; phase: ProjectPhase; selected: boolean; busy: boolean; onSelect: () => void; onGenerateImage: () => void; onAnimate: () => void; } function ShotCard({ shot, phase, selected, busy, onSelect, onGenerateImage, onAnimate }: ShotCardProps) { const imageUrl = mediaUrl(shot.image_file); const videoUrl = mediaUrl(shot.video_file); return (
{videoUrl ? (

{shot.description || no description}

{phase === "outline" || !imageUrl ? ( ) : ( <> )}
); } interface ShotEditorProps { shot: Shot; phase: ProjectPhase; saving: boolean; onPatch: (patch: Partial) => void; onGenerate: () => void; onAnimate: () => void; } function ShotEditor({ shot, phase, saving, onPatch, onGenerate, onAnimate }: ShotEditorProps) { const [draft, setDraft] = useState({ description: shot.description || "", image_prompt: shot.image_prompt || "", motion_prompt: shot.motion_prompt || "", }); useEffect(() => { setDraft({ description: shot.description || "", image_prompt: shot.image_prompt || "", motion_prompt: shot.motion_prompt || "", }); }, [shot.id]); // eslint-disable-line react-hooks/exhaustive-deps const dirty = draft.description !== (shot.description || "") || draft.image_prompt !== (shot.image_prompt || "") || draft.motion_prompt !== (shot.motion_prompt || ""); return (