File size: 8,372 Bytes
fe029e8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 | 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>
);
}
|