nemoflix-amd-video / studio /src /components /ProjectsView.tsx
ortegarod's picture
Update Docker Studio UI for projects workflow
fe029e8
import { useEffect, useState } from "react";
import { Bot, Clock, Film, RefreshCw, Sparkles, UserCircle } from "lucide-react";
interface Project {
id: string;
title: string;
content?: string | null;
synopsis?: string | null;
description?: string | null;
aspect_ratio: string;
duration_seconds: number | null;
status: string;
characters: string[];
metadata: Record<string, unknown>;
created_at?: string;
updated_at?: string;
}
interface ProjectsResponse {
projects?: Project[];
}
interface ProjectsViewProps {
compact?: boolean;
onOpenProject?: (id: string) => void;
}
const STATUS_STYLES: Record<string, string> = {
draft: "border-gray-700/60 bg-gray-800/40 text-gray-400",
planning: "border-violet-500/30 bg-violet-500/10 text-violet-300",
ready: "border-emerald-500/30 bg-emerald-500/10 text-emerald-300",
rendering: "border-amber-500/30 bg-amber-500/10 text-amber-300",
completed: "border-blue-500/30 bg-blue-500/10 text-blue-300",
failed: "border-red-500/30 bg-red-500/10 text-red-300",
};
function formatRelative(iso?: string): string {
if (!iso) return "—";
const then = new Date(iso).getTime();
if (Number.isNaN(then)) return "—";
const diff = Date.now() - then;
const mins = Math.round(diff / 60000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hours = Math.round(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
return `${days}d ago`;
}
function projectText(project: Project): string | null {
return project.synopsis || project.content || project.description || null;
}
export function ProjectsView({ compact = false, onOpenProject }: ProjectsViewProps) {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
async function load() {
setError(null);
try {
const response = await fetch("/api/projects");
if (!response.ok) throw new Error(`/api/projects returned ${response.status}`);
const data = await response.json() as ProjectsResponse;
setProjects(data.projects || []);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load projects");
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
}, []);
return (
<div className={compact ? "h-full overflow-y-auto p-4 space-y-5" : "h-full overflow-y-auto p-8 space-y-6"}>
<section className="space-y-2">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-gradient-to-br from-violet-500 to-indigo-500 flex items-center justify-center shadow-lg shadow-violet-500/20 ring-1 ring-white/10">
<Film className="w-5 h-5 text-white" />
</div>
<div className="min-w-0">
<h2 className="text-sm font-bold tracking-tight text-gray-100">Projects</h2>
<p className="text-xs text-gray-500 leading-relaxed">Multi-shot stories your agent is directing.</p>
</div>
<button
onClick={() => { setLoading(true); load(); }}
className="ml-auto w-8 h-8 rounded-xl border border-gray-800 bg-gray-900/40 text-gray-500 hover:text-gray-200 hover:border-gray-600 transition flex items-center justify-center"
title="Refresh projects"
>
<RefreshCw className={`w-3.5 h-3.5 ${loading ? "animate-spin" : ""}`} />
</button>
</div>
</section>
<section className="rounded-2xl border border-rose-600/20 bg-gradient-to-b from-rose-950/10 to-gray-950 p-4 space-y-3">
<div className="flex items-center gap-2">
<Sparkles className="w-4 h-4 text-rose-300" />
<p className="text-xs font-semibold text-rose-200 uppercase tracking-wider">What this tab is for</p>
</div>
<p className="text-xs text-gray-300 leading-relaxed">
This is not raw generation. This is where a rough idea becomes a structured short: synopsis, scenes, shots, and image prompts.
</p>
<div className="rounded-xl border border-gray-800/60 bg-black/30 p-3 space-y-1.5">
<p className="text-[10px] uppercase tracking-wider text-gray-500">Tell your agent</p>
<p className="text-[11px] text-gray-300 italic leading-relaxed">“Put me inside an Iron Man-style short. Workshop, helmet reveal, first flight. Around 30 seconds.”</p>
<p className="text-[11px] text-gray-300 italic leading-relaxed">“Make a dark cyberpunk teaser with my character walking through neon rain.”</p>
</div>
</section>
<section className="rounded-2xl border border-violet-600/20 bg-gradient-to-b from-violet-950/10 to-gray-950 p-4 space-y-3">
<div className="flex items-center gap-2">
<Bot className="w-4 h-4 text-violet-300" />
<p className="text-xs font-semibold text-violet-200 uppercase tracking-wider">Agent workflow</p>
</div>
<ol className="text-[11px] text-gray-400 space-y-1.5 leading-relaxed list-decimal pl-4">
<li>Agent writes the project outline in conversation.</li>
<li>Agent creates project, scenes, and shots through the API.</li>
<li>User reviews the outline here before rendering.</li>
<li>Agent generates storyboard images shot by shot.</li>
<li>Approved images are animated into video clips.</li>
</ol>
</section>
<section className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-[10px] uppercase tracking-wider text-gray-500 font-medium">Existing projects</p>
<span className="text-[10px] text-gray-600">{projects.length}</span>
</div>
{error && (
<div className="rounded-xl border border-red-500/30 bg-red-950/20 px-3 py-2 text-xs text-red-300">
{error}
</div>
)}
{!loading && !error && projects.length === 0 && (
<div className="rounded-xl border border-gray-800/60 bg-gray-900/30 p-5 text-center">
<UserCircle className="w-5 h-5 text-gray-600 mx-auto mb-2" />
<p className="text-xs text-gray-400">No projects yet.</p>
<p className="text-[11px] text-gray-600 mt-1 leading-relaxed">Ask your agent to draft one, then it will appear here.</p>
</div>
)}
{projects.map((project) => {
const statusStyle = STATUS_STYLES[project.status] || STATUS_STYLES.draft;
const text = projectText(project);
return (
<article
key={project.id}
onClick={() => onOpenProject?.(project.id)}
className={`rounded-xl border border-gray-800/60 bg-gray-900/30 p-3 space-y-2 ${onOpenProject ? "cursor-pointer hover:bg-gray-900/50 hover:border-gray-700 transition" : ""}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="text-xs font-semibold text-gray-100 truncate">{project.title}</h3>
{text && <p className="text-[11px] text-gray-500 mt-1 line-clamp-2 leading-relaxed">{text}</p>}
</div>
<span className={`flex-shrink-0 text-[9px] uppercase tracking-wider rounded-full border px-1.5 py-0.5 ${statusStyle}`}>
{project.status}
</span>
</div>
<div className="flex items-center gap-3 pt-2 border-t border-gray-800/40 text-[10px] text-gray-600">
<span className="font-mono">{project.aspect_ratio}</span>
{project.duration_seconds !== null && (
<span className="flex items-center gap-1"><Clock className="w-3 h-3" />{project.duration_seconds}s</span>
)}
<span className="ml-auto">{formatRelative(project.updated_at)}</span>
</div>
</article>
);
})}
{loading && projects.length === 0 && !error && (
<div className="text-center py-8">
<RefreshCw className="w-5 h-5 text-gray-600 mx-auto animate-spin" />
<p className="text-xs text-gray-600 mt-2">Loading projects…</p>
</div>
)}
</section>
</div>
);
}