Spaces:
Paused
Paused
| import { useCallback, useEffect, useState } from 'react' | |
| import { api } from './api' | |
| import { loadAllBundles } from './persist' | |
| import { ErrorBox } from './hooks' | |
| import Stepper from './components/Stepper' | |
| import ReelBar from './components/ReelBar' | |
| import CloneBar from './components/CloneBar' | |
| import SetupStep from './steps/SetupStep' | |
| import ReferencesStep from './steps/ReferencesStep' | |
| import TopicsStep from './steps/TopicsStep' | |
| import ScriptStep from './steps/ScriptStep' | |
| import MediaStep from './steps/MediaStep' | |
| import EffectsStep from './steps/EffectsStep' | |
| import RenderStep from './steps/RenderStep' | |
| import ReviewStep from './steps/ReviewStep' | |
| export const STEPS = ['Setup', 'References', 'Topics', 'Script', 'Media', 'Effects', 'Render', 'Review'] | |
| const REEL_STEP_START = 3 // steps >= this operate on the selected reel | |
| export default function App() { | |
| const [projects, setProjects] = useState(null) | |
| const [projectId, setProjectId] = useState(null) | |
| const [data, setData] = useState(null) // project-level: {project, style, topics, reels} | |
| const [reelId, setReelId] = useState(null) | |
| const [reelData, setReelData] = useState(null) // {reel, script, voice, media, renders, clone} | |
| const [step, setStep] = useState(0) | |
| const [error, setError] = useState('') | |
| // Replay a browser-cached project onto the server (returns true if it did). | |
| // Used when the server (ephemeral hosting) has wiped a project we still hold. | |
| const restoreFromCache = useCallback(async (pid) => { | |
| const bundle = (await loadAllBundles()).find((b) => b.project_id === pid) | |
| if (!bundle) return false | |
| try { await api.post('/api/projects/import', bundle); return true } catch { return false } | |
| }, []) | |
| const loadProjects = useCallback(async () => { | |
| try { | |
| let serverList = await api.get('/api/projects') | |
| const bundles = await loadAllBundles() | |
| const serverIds = new Set(serverList.map((p) => p.id)) | |
| const missing = bundles.filter((b) => b.project_id && !serverIds.has(b.project_id)) | |
| if (missing.length) { | |
| // Server lost these (restart/wipe) but the browser still has them. | |
| await Promise.all(missing.map((b) => api.post('/api/projects/import', b).catch(() => {}))) | |
| serverList = await api.get('/api/projects') | |
| } | |
| setProjects(serverList) | |
| } catch (e) { | |
| setError(e.message) | |
| } | |
| }, []) | |
| const refreshProject = useCallback(async () => { | |
| if (!projectId) return null | |
| try { | |
| const d = await api.get(`/api/projects/${projectId}`) | |
| setData(d) | |
| return d | |
| } catch (e) { | |
| if (await restoreFromCache(projectId)) { | |
| try { const d = await api.get(`/api/projects/${projectId}`); setData(d); return d } catch { /* fall through */ } | |
| } | |
| setError(e.message) | |
| return null | |
| } | |
| }, [projectId, restoreFromCache]) | |
| const refreshReel = useCallback(async () => { | |
| if (!projectId || !reelId) { setReelData(null); return null } | |
| try { | |
| const d = await api.get(`/api/projects/${projectId}/reels/${reelId}`) | |
| setReelData(d) | |
| return d | |
| } catch (e) { | |
| if (await restoreFromCache(projectId)) { | |
| try { const d = await api.get(`/api/projects/${projectId}/reels/${reelId}`); setReelData(d); return d } catch { /* fall through */ } | |
| } | |
| setError(e.message) | |
| return null | |
| } | |
| }, [projectId, reelId, restoreFromCache]) | |
| useEffect(() => { loadProjects() }, [loadProjects]) | |
| useEffect(() => { setData(null); setReelId(null); setReelData(null); refreshProject() }, [projectId, refreshProject]) | |
| useEffect(() => { refreshReel() }, [reelId, refreshReel]) | |
| const selectReel = (id) => { setReelId(id); setStep(REEL_STEP_START) } | |
| const newReel = async () => { | |
| const reel = await api.post(`/api/projects/${projectId}/reels`, {}).catch((e) => { setError(e.message) }) | |
| if (reel) { await refreshProject(); selectReel(reel.id) } | |
| } | |
| const deleteReel = async (id) => { | |
| if (!window.confirm('Delete this reel and all its media?')) return | |
| await api.del(`/api/projects/${projectId}/reels/${id}`).catch((e) => setError(e.message)) | |
| if (id === reelId) { setReelId(null); setStep(2) } | |
| await refreshProject() | |
| } | |
| const canEnter = (i) => { | |
| if (!data) return i === 0 | |
| if (i < REEL_STEP_START) return true | |
| if (!reelId) return false | |
| if (i === 3) return true // Script (offers generate) | |
| if (i === 4) return !!reelData?.script // Media | |
| if (i === 5) return !!reelData?.voice && !!reelData?.media // Effects | |
| if (i === 6) return !!reelData?.voice && !!reelData?.media // Render | |
| if (i === 7) return !!(reelData?.renders?.results?.length) // Review | |
| return false | |
| } | |
| if (!projectId) { | |
| return ( | |
| <Home | |
| projects={projects} | |
| error={error} | |
| onOpen={(id) => { setProjectId(id); setStep(1) }} | |
| onCreate={(project) => { setProjectId(project.id); setStep(1); loadProjects() }} | |
| onDelete={async (id) => { | |
| if (!window.confirm('Delete this project and all its reels?')) return | |
| await api.del(`/api/projects/${id}`).catch((e) => setError(e.message)) | |
| loadProjects() | |
| }} | |
| /> | |
| ) | |
| } | |
| // Props shared with every step. | |
| const shared = { | |
| projectId, data, refreshProject, | |
| reelId, reelData, refreshReel, selectReel, setReelId, | |
| setStep, | |
| } | |
| return ( | |
| <div className="layout"> | |
| <div className="sidebar"> | |
| <h1>Reel <span>Studio</span> v2</h1> | |
| <button className="home-link" onClick={() => { setProjectId(null); loadProjects() }}> | |
| ← All projects | |
| </button> | |
| {data && <div className="project-name" title={data.project.name}>{data.project.name}</div>} | |
| <Stepper steps={STEPS} current={step} canEnter={canEnter} onSelect={setStep} /> | |
| {data && ( | |
| <ReelBar | |
| reels={data.reels || []} | |
| reelId={reelId} | |
| onSelect={selectReel} | |
| onNew={newReel} | |
| onDelete={deleteReel} | |
| /> | |
| )} | |
| </div> | |
| <div className="main"> | |
| <ErrorBox error={error} onRetry={() => { setError(''); refreshProject() }} /> | |
| {!data && !error && <p className="muted"><span className="spinner" />Loading project…</p>} | |
| {data && ( | |
| <> | |
| <CloneBar | |
| projectId={projectId} | |
| onCloned={async (reel) => { await refreshProject(); selectReel(reel.id) }} | |
| /> | |
| {step === 0 && <SetupStep {...shared} mode="edit" />} | |
| {step === 1 && <ReferencesStep {...shared} />} | |
| {step === 2 && <TopicsStep {...shared} />} | |
| {step === 3 && <ScriptStep {...shared} />} | |
| {step === 4 && <MediaStep {...shared} />} | |
| {step === 5 && <EffectsStep {...shared} />} | |
| {step === 6 && <RenderStep {...shared} />} | |
| {step === 7 && <ReviewStep {...shared} />} | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| ) | |
| } | |
| function Home({ projects, error, onOpen, onCreate, onDelete }) { | |
| const [creating, setCreating] = useState(false) | |
| return ( | |
| <div className="layout"> | |
| <div className="main" style={{ margin: '0 auto' }}> | |
| <h2>Reel Studio v2</h2> | |
| <p className="subtitle">AI short-form video pipeline — pick a project or start a new one.</p> | |
| <ErrorBox error={error} /> | |
| {creating ? ( | |
| <SetupStep mode="create" onCreated={onCreate} onCancel={() => setCreating(false)} /> | |
| ) : ( | |
| <> | |
| <button className="btn" onClick={() => setCreating(true)}>+ New project</button> | |
| <div className="spacer" /> | |
| {projects === null && <p className="muted"><span className="spinner" />Loading…</p>} | |
| {projects?.length === 0 && <p className="muted">No projects yet.</p>} | |
| {projects?.map((p) => ( | |
| <div key={p.id} className="project-tile" onClick={() => onOpen(p.id)}> | |
| <div> | |
| <strong>{p.name}</strong> | |
| <div className="muted">{p.niche.topic} · {p.format.replace('_', ' ')}</div> | |
| </div> | |
| <button | |
| className="btn small danger" | |
| onClick={(e) => { e.stopPropagation(); onDelete(p.id) }} | |
| > | |
| Delete | |
| </button> | |
| </div> | |
| ))} | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| ) | |
| } | |