reel-studio-2 / web /src /App.jsx
xvepkj's picture
Add hosting: Docker, browser-backed persistence, named-account auth
c5e0205
Raw
History Blame
8.41 kB
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>
)
}