import { createContext, useContext, useState, useEffect, useCallback, useRef } from "react"; import { BrowserRouter, Routes, Route, Outlet, useMatch, useNavigate, useParams, Link } from "react-router-dom"; import { Menu, Sparkles, UserCircle } from "lucide-react"; import { StudioView } from "./components/GalleryView"; import { CharacterProfileView } from "./components/CharacterProfileView"; import { ProjectsView } from "./components/ProjectsView"; import { LoraTrainingPage } from "./components/LoraTrainingPage"; import { ProjectDetailView } from "./components/ProjectDetailView"; import { ProjectFilmsView } from "./components/ProjectFilmsView"; import { AppSidebar } from "./components/sidebar/AppSidebar"; import LandingPage from "./LandingPage"; import type { SidebarTab } from "./components/sidebar/AppSidebar"; import type { JobItem, LoraCheckpoint, LoraTrainingStatus, MediaItem, Project, Scene, Shot, ProjectPhase, ProjectModeData } from "./types"; async function fetchJson(url: string, timeoutMs = 5000): Promise { const controller = new AbortController(); const id = window.setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { signal: controller.signal }); if (!response.ok) throw new Error(`${url} returned ${response.status}`); return await response.json(); } finally { window.clearTimeout(id); } } /* ── App Context ── */ interface AppContextType { items: MediaItem[]; jobs: JobItem[]; loading: boolean; hasLoadedOnce: boolean; error: string | null; selected: string | null; setSelected: (url: string | null) => void; sidebarOpen: boolean; setSidebarOpen: (v: boolean) => void; activeSidebarTab: SidebarTab; setActiveSidebarTab: (tab: SidebarTab) => void; training: LoraTrainingStatus | null; checkpoints: LoraCheckpoint[]; trainingJobs: any[]; deleteItem: (item: MediaItem) => Promise; load: () => Promise; projectData: { project: Project; scenes: Scene[]; shots: Shot[] } | null; selectedSceneId: string | null; selectedShotId: string | null; setSelectedSceneId: (id: string | null) => void; setSelectedShotId: (id: string | null) => void; loadProject: (id: string) => Promise; addScene: () => Promise; deleteScene: (sceneId: string) => Promise; deleteShot: (shotId: string) => Promise; } const AppContext = createContext(null!); export function useApp() { return useContext(AppContext); } /* ── Layout Shell: header + sidebar + ── */ function Shell() { const ctx = useApp(); const navigate = useNavigate(); const projectMatch = useMatch("/studio/projects/:projectId"); const loraMatch = useMatch("/studio/lora-training"); // Load project when entering a project-detail route const lastProjectId = useRef(undefined); useEffect(() => { const pid = projectMatch?.params.projectId; if (pid && pid !== lastProjectId.current) { lastProjectId.current = pid; ctx.loadProject(pid); ctx.setActiveSidebarTab("projects"); } }, [projectMatch?.params.projectId]); // Keep "Characters & LoRA Training" sidebar tab highlighted when on /lora-training useEffect(() => { if (loraMatch) ctx.setActiveSidebarTab("characters"); }, [loraMatch]); const videoCount = ctx.items.filter( (item) => item.type === "video" || item.url.endsWith(".mp4") || item.url.endsWith(".webm") ).length; const imageCount = ctx.items.length - videoCount; const projectMode: ProjectModeData | undefined = projectMatch && ctx.projectData ? { project: ctx.projectData.project, scenes: ctx.projectData.scenes, shots: ctx.projectData.shots, selectedSceneId: ctx.selectedSceneId, selectedShotId: ctx.selectedShotId, phase: "outline", onSelectScene: (id) => { ctx.setSelectedSceneId(id); ctx.setSelectedShotId(null); }, onSelectShot: ctx.setSelectedShotId, onBack: () => { ctx.setSelectedShotId(null); navigate("/studio/projects"); }, onRefresh: () => { const pid = projectMatch?.params.projectId; return pid ? ctx.loadProject(pid) : Promise.resolve(); }, onAddScene: ctx.addScene, onDeleteScene: ctx.deleteScene, onDeleteShot: ctx.deleteShot, } : undefined; return (
{!ctx.sidebarOpen && ( )} ← Home
{ctx.jobs.length > 0 && ( {ctx.jobs.length} generating )} {ctx.items.length} media {imageCount} images {videoCount} videos

Demo workspace

This hackathon build uses a sample owner dataset to demonstrate character LoRA training, generation, and media management. Authentication is intentionally mocked for the demo.

{ctx.sidebarOpen && ( { ctx.setActiveSidebarTab(tab); if (tab === "generate") navigate("/studio"); if (tab === "projects") navigate("/studio/projects"); if (tab === "characters") navigate("/studio/lora-training"); }} onClose={() => ctx.setSidebarOpen(false)} checkpoints={ctx.checkpoints} onQueued={ctx.load} onSelectCharacter={(id) => { ctx.setActiveSidebarTab("characters"); navigate(`/studio/characters/${id}`); }} projectMode={projectMode} /> )}
{/* Lightbox */} {ctx.selected && (
ctx.setSelected(null)} >
e.stopPropagation()}> {ctx.selected.endsWith(".mp4") || ctx.selected.endsWith(".webm") ? (
)}
); } /* ── Route wrappers that connect URL params to existing components ── */ function StudioRoute() { const ctx = useApp(); return ( 0 || ctx.items.length > 0)} error={ctx.error} onOpen={ctx.setSelected} onDelete={ctx.deleteItem} onOpenProjects={() => window.location.href = "/studio/projects"} /> ); } function ProjectsRoute() { const navigate = useNavigate(); return navigate(`/studio/projects/${id}`)} />; } function ProjectRoute() { const { projectId } = useParams<{ projectId: string }>(); const ctx = useApp(); useEffect(() => { if (projectId) ctx.loadProject(projectId); }, [projectId]); const navigate = useNavigate(); if (!ctx.projectData) { return
Loading project…
; } return ( { ctx.setSelectedSceneId(id); ctx.setSelectedShotId(null); }} onSelectShot={ctx.setSelectedShotId} onRefresh={() => (projectId ? ctx.loadProject(projectId) : Promise.resolve())} onBack={() => { ctx.setSelectedShotId(null); navigate("/studio/projects"); }} onDeleteScene={ctx.deleteScene} onDeleteShot={ctx.deleteShot} /> ); } function ProjectFilmsRoute() { const { projectId } = useParams<{ projectId: string }>(); const navigate = useNavigate(); if (!projectId) return null; return ( navigate(`/studio/projects/${projectId}`)} /> ); } function CharacterRoute() { const { characterId } = useParams<{ characterId: string }>(); const { items, setSelected, deleteItem, setActiveSidebarTab, setSidebarOpen } = useApp(); const navigate = useNavigate(); if (!characterId) return null; return ( { setSidebarOpen(true); setActiveSidebarTab("generate"); navigate(`/studio?character=${characterId}`); }} /> ); } /* ── App Root ── */ export default function App() { const [items, setItems] = useState([]); const [jobs, setJobs] = useState([]); const [training, setTraining] = useState(null); const [checkpoints, setCheckpoints] = useState([]); const [trainingJobs, setTrainingJobs] = useState([]); const [loading, setLoading] = useState(true); const [hasLoadedOnce, setHasLoadedOnce] = useState(false); const [error, setError] = useState(null); const [selected, setSelected] = useState(null); const [sidebarOpen, setSidebarOpen] = useState(true); const [activeSidebarTab, setActiveSidebarTab] = useState("generate"); const [projectData, setProjectData] = useState<{ project: Project; scenes: Scene[]; shots: Shot[]; } | null>(null); const [selectedSceneId, setSelectedSceneId] = useState(null); const [selectedShotId, setSelectedShotId] = useState(null); const [projectId, setProjectId] = useState(null); const load = useCallback(async () => { try { setError(null); const listing = await fetchJson<{ images?: MediaItem[] }>("/api/listing", 8000); setItems(listing.images || []); if (!hasLoadedOnce) setHasLoadedOnce(true); } catch (e) { console.error("Failed to load gallery", e); setError(e instanceof Error ? e.message : "Failed to load gallery"); } finally { setLoading(false); } const [jobsResult, trainingResult, checkpointsResult, trainingJobsResult] = await Promise.allSettled([ fetchJson<{ jobs?: JobItem[] }>("/api/jobs", 3500), fetchJson("/api/lora-training/status", 3500), fetchJson<{ checkpoints?: LoraCheckpoint[] }>("/api/lora-training/checkpoints", 3500), fetchJson<{ jobs?: any[] }>("/api/lora-training/jobs", 3500), ]); if (jobsResult.status === "fulfilled") setJobs(jobsResult.value.jobs || []); if (trainingResult.status === "fulfilled") setTraining(trainingResult.value.ok ? trainingResult.value : null); if (checkpointsResult.status === "fulfilled") setCheckpoints(checkpointsResult.value.checkpoints || []); if (trainingJobsResult.status === "fulfilled") setTrainingJobs(trainingJobsResult.value.jobs || []); }, []); // Stable polling — uses refs to avoid dependency churn const loadRef = useRef(load); loadRef.current = load; useEffect(() => { loadRef.current(); const id = window.setInterval(() => loadRef.current(), 5000); const es = new EventSource("/api/events"); es.addEventListener("job_update", () => loadRef.current()); es.onerror = () => {}; return () => { window.clearInterval(id); es.close(); }; }, []); const loadProject = useCallback(async (id: string) => { setProjectId(id); try { const response = await fetch(`/api/projects/${id}`); if (!response.ok) throw new Error(`Project fetch failed: ${response.status}`); const data = await response.json(); const scenes: Scene[] = (data.scenes || []).slice().sort( (a: Scene, b: Scene) => a.scene_number - b.scene_number ); const shots: Shot[] = (data.shots || []).slice().sort( (a: Shot, b: Shot) => a.shot_number - b.shot_number ); setProjectData({ project: data.project, scenes, shots }); setSelectedSceneId((current) => { if (current && scenes.some((scene) => scene.id === current)) return current; return scenes.length > 0 ? scenes[0].id : null; }); } catch (e) { console.error("Failed to load project", e); } }, []); const addScene = useCallback(async () => { if (!projectId || !projectData) return; const next = projectData.scenes.length > 0 ? Math.max(...projectData.scenes.map((scene) => scene.scene_number)) + 1 : 1; try { const response = await fetch(`/api/projects/${projectId}/scenes`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scene_number: next, heading: `SCENE ${next}` }), }); if (!response.ok) throw new Error(`Add scene failed: ${response.status}`); const created = await response.json(); await loadProject(projectId); setSelectedSceneId(created.id); setSelectedShotId(null); } catch (e) { console.error("Failed to add scene", e); } }, [projectId, projectData, loadProject]); const deleteScene = useCallback(async (sceneId: string) => { if (!projectId) return; if (!confirm("Delete this scene and all its shots?")) return; await fetch(`/api/projects/${projectId}/scenes/${sceneId}`, { method: "DELETE" }); if (selectedSceneId === sceneId) { setSelectedSceneId(""); setSelectedShotId(null); } loadProject(projectId); }, [projectId, selectedSceneId, loadProject]); const deleteShot = useCallback(async (shotId: string) => { if (!projectId || !projectData) return; if (!confirm("Delete this shot?")) return; const shot = projectData.shots.find((s) => s.id === shotId); if (!shot) return; await fetch(`/api/projects/${projectId}/scenes/${shot.scene_id}/shots/${shotId}`, { method: "DELETE" }); if (selectedShotId === shotId) setSelectedShotId(null); loadProject(projectId); }, [projectId, projectData, selectedShotId, loadProject]); const deleteItem = useCallback( async (item: MediaItem) => { const filename = item.filename || item.url.replace(/^\/media\//, ""); const response = await fetch("/api/delete", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ files: [filename] }), }); if (!response.ok) { const data = await response.json().catch(() => ({})); throw new Error(data?.detail || `Failed to delete ${filename}`); } setItems((current) => current.filter( (candidate) => (candidate.filename || candidate.url) !== (item.filename || item.url) ) ); if (selected === item.url) setSelected(null); }, [selected] ); const ctxValue: AppContextType = { items, jobs, loading, hasLoadedOnce, error, selected, setSelected, sidebarOpen, setSidebarOpen, activeSidebarTab, setActiveSidebarTab, training, checkpoints, trainingJobs, deleteItem, load, projectData, selectedSceneId, selectedShotId, setSelectedSceneId, setSelectedShotId, loadProject, addScene, deleteScene, deleteShot, }; return ( } /> }> } /> } /> } /> } /> } /> } /> ); }