Spaces:
Running
Running
| "use client"; | |
| import React, { useState, useEffect } from "react"; | |
| import { useSearchParams } from "next/navigation"; | |
| import { motion, AnimatePresence } from "framer-motion"; | |
| import { Loader2, Upload, Link as LinkIcon, FileText, CheckCircle, AlertCircle, Download, Play, Layout, Clock, Settings, Menu, X, Video, ChevronRight, Sparkles } from "lucide-react"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Input, Textarea } from "@/components/ui/input"; | |
| import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | |
| import { api, type InputType, type Category, type JobStatus } from "@/lib/api"; | |
| import { cn } from "@/lib/utils"; | |
| import Link from "next/link"; | |
| import { supabaseClient } from "@/lib/supabase-client"; | |
| export function VideoGenerator() { | |
| const searchParams = useSearchParams(); | |
| const [inputType, setInputType] = useState<InputType>("text"); | |
| const [category, setCategory] = useState<Category>("tech_system"); | |
| const [content, setContent] = useState(""); | |
| const [isGenerating, setIsGenerating] = useState(false); | |
| const [currentJob, setCurrentJob] = useState<JobStatus | null>(null); | |
| const [error, setError] = useState<string | null>(null); | |
| const [isSidebarOpen, setIsSidebarOpen] = useState(false); | |
| const [credits, setCredits] = useState(0); | |
| const [demoVideoUrl, setDemoVideoUrl] = useState<string | null>(null); | |
| const supabase = supabaseClient; | |
| // Fetch credits | |
| useEffect(() => { | |
| const fetchCredits = async () => { | |
| const { data: { user } } = await supabase.auth.getUser(); | |
| if (user) { | |
| const { data } = await supabase | |
| .from("users") | |
| .select("credits") | |
| .eq("id", user.id) | |
| .single(); | |
| setCredits(data?.credits ?? 0); | |
| } | |
| }; | |
| fetchCredits(); | |
| }, [supabase]); | |
| // Initialize from URL params | |
| useEffect(() => { | |
| const promptParam = searchParams.get("prompt"); | |
| const categoryParam = searchParams.get("category"); | |
| const demoUrlParam = searchParams.get("demoVideoUrl"); | |
| if (promptParam) { | |
| setContent(promptParam); | |
| setInputType("text"); | |
| } | |
| if (categoryParam && ["tech_system", "product_startup", "mathematical"].includes(categoryParam)) { | |
| setCategory(categoryParam as Category); | |
| } | |
| if (demoUrlParam) { | |
| setDemoVideoUrl(demoUrlParam); | |
| // Removed auto-start: simulateGeneration(demoUrlParam); | |
| } else { | |
| setDemoVideoUrl(null); | |
| } | |
| }, [searchParams]); | |
| const simulateGeneration = (videoUrl: string) => { | |
| setIsGenerating(true); | |
| const jobId = "demo-" + Date.now(); | |
| const steps = [ | |
| { percentage: 10, message: "Analyzing Input..." }, | |
| { percentage: 30, message: "Generating Script..." }, | |
| { percentage: 60, message: "Validating Code..." }, | |
| { percentage: 85, message: "Rendering Frames..." }, | |
| { percentage: 100, message: "Finalizing..." } | |
| ]; | |
| let step = 0; | |
| // Clear any existing job | |
| setCurrentJob({ | |
| job_id: jobId, | |
| status: "pending", | |
| progress: { percentage: 0, message: "Initializing..." }, | |
| created_at: new Date().toISOString() | |
| }); | |
| const interval = setInterval(() => { | |
| if (step >= steps.length) { | |
| clearInterval(interval); | |
| setCurrentJob({ | |
| job_id: jobId, | |
| status: "completed", | |
| progress: { percentage: 100, message: "Completed" }, | |
| created_at: new Date().toISOString() | |
| }); | |
| setIsGenerating(false); | |
| return; | |
| } | |
| setCurrentJob({ | |
| job_id: jobId, | |
| status: "rendering", | |
| progress: steps[step], | |
| created_at: new Date().toISOString() | |
| }); | |
| step++; | |
| }, 1000); // 1 second per step for a nice flow | |
| }; | |
| // Poll for job status | |
| useEffect(() => { | |
| let interval: NodeJS.Timeout; | |
| // Only poll if it's NOT a demo job (demo jobs start with "demo-") | |
| if (currentJob && ["pending", "generating_code", "rendering"].includes(currentJob.status) && !currentJob.job_id.startsWith("demo-")) { | |
| interval = setInterval(async () => { | |
| try { | |
| const status = await api.getJobStatus(currentJob.job_id); | |
| setCurrentJob(status); | |
| if (status.status === "failed") { | |
| setError(status.error || "Job failed"); | |
| setIsGenerating(false); | |
| } else if (status.status === "completed") { | |
| setIsGenerating(false); | |
| } | |
| } catch (e) { | |
| console.error("Polling error", e); | |
| } | |
| }, 2000); | |
| } | |
| return () => clearInterval(interval); | |
| }, [currentJob]); | |
| const handleSubmit = async (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| if (!content) return; | |
| // If we have a demo video URL, simulate the generation instead of calling the API | |
| if (demoVideoUrl) { | |
| simulateGeneration(demoVideoUrl); | |
| return; | |
| } | |
| setIsGenerating(true); | |
| setError(null); | |
| setCurrentJob(null); | |
| // setDemoVideoUrl(null); // Don't clear it here, we might want to keep it if user retries? | |
| // Actually, if it's a real generation, we should probably clear it. | |
| // But wait, if we are here, demoVideoUrl is NULL (because of the check above). | |
| // So we don't need to clear it. | |
| try { | |
| const job = await api.createVideo(content, inputType, category); | |
| setCurrentJob({ | |
| job_id: job.job_id, | |
| status: "pending", | |
| progress: { percentage: 0, message: "Initializing system..." }, | |
| created_at: new Date().toISOString() | |
| }); | |
| // Optimistically update credits | |
| setCredits(prev => Math.max(0, prev - 1)); | |
| } catch (e: any) { | |
| setError(e.message); | |
| setIsGenerating(false); | |
| } | |
| }; | |
| const categories: { value: Category; label: string; description: string }[] = [ | |
| { value: "tech_system", label: "Tech & Systems", description: "Architecture, Data Flow, APIs" }, | |
| { value: "product_startup", label: "Product Demo", description: "Features, Value Prop, UI/UX" }, | |
| { value: "mathematical", label: "Math & Research", description: "Equations, Graphs, Concepts" }, | |
| ]; | |
| const SHOWCASE_KEYWORDS = [ | |
| "database sharding", | |
| "kafka", | |
| "transformers", | |
| "quantum entanglement", | |
| "netflix", | |
| "black hole", | |
| "sorting algorithms", | |
| "uber", | |
| "sorting", | |
| "bubble sort", | |
| "url shortener" | |
| ]; | |
| const isShowcasePrompt = SHOWCASE_KEYWORDS.some(keyword => (content || "").toLowerCase().includes(keyword)); | |
| const canEdit = credits > 0; | |
| const canGenerate = canEdit || isShowcasePrompt; | |
| return ( | |
| <div className="flex h-screen bg-slate-950 overflow-hidden"> | |
| {/* Mobile Sidebar Overlay */} | |
| <AnimatePresence> | |
| {isSidebarOpen && ( | |
| <motion.div | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| onClick={() => setIsSidebarOpen(false)} | |
| className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 md:hidden" | |
| /> | |
| )} | |
| </AnimatePresence> | |
| {/* Sidebar */} | |
| <motion.div | |
| className={cn( | |
| "fixed inset-y-0 left-0 z-50 w-72 bg-slate-900/50 backdrop-blur-xl border-r border-white/5 flex flex-col transition-transform duration-300 md:translate-x-0 md:static", | |
| isSidebarOpen ? "translate-x-0" : "-translate-x-full" | |
| )} | |
| > | |
| <div className="p-6 border-b border-white/5 flex items-center justify-between"> | |
| <Link href="/" className="flex items-center gap-2"> | |
| <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-violet-600"> | |
| <Video className="h-4 w-4 text-white" /> | |
| </div> | |
| <span className="text-lg font-bold text-white">VidSimplify</span> | |
| </Link> | |
| <button onClick={() => setIsSidebarOpen(false)} className="md:hidden text-slate-400 hover:text-white"> | |
| <X className="h-5 w-5" /> | |
| </button> | |
| </div> | |
| <div className="flex-1 py-6 px-4 space-y-2 overflow-y-auto custom-scrollbar"> | |
| <Button variant="ghost" className="w-full justify-start text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 hover:text-blue-300 font-medium"> | |
| <Layout className="mr-3 h-4 w-4" /> | |
| Create New | |
| </Button> | |
| <Button variant="ghost" className="w-full justify-start text-slate-400 hover:text-white hover:bg-white/5"> | |
| <Clock className="mr-3 h-4 w-4" /> | |
| History | |
| </Button> | |
| <Button variant="ghost" className="w-full justify-start text-slate-400 hover:text-white hover:bg-white/5"> | |
| <Settings className="mr-3 h-4 w-4" /> | |
| Settings | |
| </Button> | |
| </div> | |
| <div className="p-4 border-t border-white/5 bg-slate-900/50"> | |
| <div className="bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl p-4 border border-white/5"> | |
| <div className="flex justify-between items-center mb-2"> | |
| <div className="text-xs font-medium text-slate-300">Credits</div> | |
| <div className="text-xs font-mono text-blue-400">{credits} / 5</div> | |
| </div> | |
| <div className="h-1.5 bg-slate-700/50 rounded-full overflow-hidden mb-3"> | |
| <div | |
| className="h-full bg-gradient-to-r from-blue-500 to-violet-500 rounded-full transition-all duration-500" | |
| style={{ width: `${Math.min((credits / 5) * 100, 100)}%` }} | |
| ></div> | |
| </div> | |
| <Button | |
| size="sm" | |
| variant="outline" | |
| className="w-full text-xs h-8 border-slate-700 hover:bg-slate-800 text-slate-300" | |
| onClick={() => window.open("https://calendly.com/aditya-vidsimplify/demo-call", "_blank")} | |
| > | |
| Upgrade Plan | |
| </Button> | |
| </div> | |
| </div> | |
| </motion.div> | |
| {/* Main Content */} | |
| <div className="flex-1 flex flex-col min-w-0 overflow-hidden relative"> | |
| {/* App Header */} | |
| <header className="h-16 border-b border-white/5 bg-slate-950/50 backdrop-blur-md flex items-center justify-between px-4 sm:px-8 z-30"> | |
| <div className="flex items-center gap-4"> | |
| <button onClick={() => setIsSidebarOpen(true)} className="md:hidden text-slate-400 hover:text-white p-1"> | |
| <Menu className="h-6 w-6" /> | |
| </button> | |
| <h1 className="text-lg font-semibold text-white flex items-center gap-2"> | |
| <span className="text-slate-500 font-normal">Project /</span> Untitled Animation | |
| </h1> | |
| </div> | |
| <div className="flex items-center gap-4"> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="hidden sm:flex text-slate-400 hover:text-white font-medium" | |
| onClick={() => window.open("https://calendly.com/aditya-vidsimplify/demo-call", "_blank")} | |
| > | |
| Book a Call | |
| </Button> | |
| <div className="hidden sm:flex items-center gap-2 px-3 py-1.5 rounded-full bg-green-500/10 border border-green-500/20 text-xs font-medium text-green-400"> | |
| <div className="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse" /> | |
| System Operational | |
| </div> | |
| <div className="w-8 h-8 rounded-full bg-gradient-to-tr from-blue-500 to-violet-500 border border-white/20 shadow-lg" /> | |
| </div> | |
| </header> | |
| <main className="flex-1 overflow-y-auto p-4 sm:p-8 custom-scrollbar"> | |
| <div className="max-w-6xl mx-auto"> | |
| <div className="grid lg:grid-cols-12 gap-8"> | |
| {/* Left Column: Input Configuration */} | |
| <div className="lg:col-span-7 space-y-8"> | |
| <div> | |
| <h2 className="text-2xl font-bold text-white mb-2">Configure Generation</h2> | |
| <p className="text-slate-400">Define the parameters for your AI-generated animation.</p> | |
| </div> | |
| <form onSubmit={handleSubmit} className="space-y-8"> | |
| {/* Input Source */} | |
| <div className="space-y-4"> | |
| <label className="text-sm font-medium text-slate-300 uppercase tracking-wider">Input Source</label> | |
| <div className="grid grid-cols-3 gap-4"> | |
| {[ | |
| { id: "text", icon: FileText, label: "Text Prompt" }, | |
| { id: "url", icon: LinkIcon, label: "URL / Blog" }, | |
| { id: "pdf", icon: Upload, label: "PDF Document" } | |
| ].map((type) => ( | |
| <button | |
| key={type.id} | |
| type="button" | |
| onClick={() => setInputType(type.id as InputType)} | |
| className={cn( | |
| "flex flex-col items-center justify-center p-4 rounded-xl border transition-all duration-200", | |
| inputType === type.id | |
| ? "bg-blue-600/10 border-blue-500/50 text-blue-400 shadow-[0_0_20px_rgba(59,130,246,0.15)]" | |
| : "bg-slate-900/50 border-white/5 text-slate-400 hover:bg-slate-800 hover:border-white/10" | |
| )} | |
| > | |
| <type.icon className="w-6 h-6 mb-2" /> | |
| <span className="text-sm font-medium">{type.label}</span> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Animation Style */} | |
| <div className="space-y-4"> | |
| <label className="text-sm font-medium text-slate-300 uppercase tracking-wider">Visual Style</label> | |
| <div className="grid gap-3"> | |
| {categories.map((cat) => ( | |
| <button | |
| key={cat.value} | |
| type="button" | |
| onClick={() => setCategory(cat.value)} | |
| className={cn( | |
| "flex items-center p-4 rounded-xl border text-left transition-all duration-200", | |
| category === cat.value | |
| ? "bg-violet-600/10 border-violet-500/50 shadow-[0_0_20px_rgba(139,92,246,0.15)]" | |
| : "bg-slate-900/50 border-white/5 hover:bg-slate-800 hover:border-white/10" | |
| )} | |
| > | |
| <div className={cn( | |
| "w-10 h-10 rounded-lg flex items-center justify-center mr-4 transition-colors", | |
| category === cat.value ? "bg-violet-500/20 text-violet-400" : "bg-slate-800 text-slate-400" | |
| )}> | |
| {cat.value === 'tech_system' && <Layout className="w-5 h-5" />} | |
| {cat.value === 'product_startup' && <Sparkles className="w-5 h-5" />} | |
| {cat.value === 'mathematical' && <Clock className="w-5 h-5" />} | |
| </div> | |
| <div> | |
| <div className={cn("font-medium", category === cat.value ? "text-violet-300" : "text-slate-200")}> | |
| {cat.label} | |
| </div> | |
| <div className="text-xs text-slate-500 mt-0.5">{cat.description}</div> | |
| </div> | |
| {category === cat.value && ( | |
| <div className="ml-auto text-violet-400"> | |
| <CheckCircle className="w-5 h-5" /> | |
| </div> | |
| )} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Content Input */} | |
| <div className="space-y-4"> | |
| <label className="text-sm font-medium text-slate-300 uppercase tracking-wider"> | |
| {inputType === "text" ? "Description" : inputType === "url" ? "Source URL" : "Upload File"} | |
| </label> | |
| <div className="relative group"> | |
| <div className="absolute -inset-0.5 bg-gradient-to-r from-blue-500 to-violet-500 rounded-xl opacity-0 group-hover:opacity-20 transition duration-500 blur"></div> | |
| {inputType === "text" ? ( | |
| <Textarea | |
| placeholder="Explain the concept of neural networks using a simple analogy..." | |
| className="relative min-h-[200px] text-base resize-none bg-slate-900/80 border-white/10 text-slate-200 focus:border-blue-500/50 focus:ring-blue-500/20 rounded-xl p-4" | |
| value={content} | |
| onChange={(e) => setContent(e.target.value)} | |
| disabled={!canEdit} | |
| /> | |
| ) : inputType === "url" ? ( | |
| <Input | |
| placeholder="https://example.com/article" | |
| value={content} | |
| onChange={(e) => setContent(e.target.value)} | |
| className="relative h-12 bg-slate-900/80 border-white/10 text-slate-200 focus:border-blue-500/50 focus:ring-blue-500/20 rounded-xl px-4" | |
| disabled={!canEdit} | |
| /> | |
| ) : ( | |
| <div className={cn( | |
| "relative border-2 border-dashed border-slate-700 rounded-xl p-12 text-center transition-all", | |
| canEdit ? "hover:border-blue-500/50 hover:bg-slate-900/50 cursor-pointer" : "opacity-50 cursor-not-allowed" | |
| )}> | |
| <Input | |
| type="file" | |
| accept=".pdf" | |
| onChange={async (e) => { | |
| const file = e.target.files?.[0]; | |
| if (file) { | |
| const reader = new FileReader(); | |
| reader.onload = (event) => { | |
| const base64 = event.target?.result as string; | |
| const base64Content = base64.split(',')[1]; | |
| setContent(base64Content); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| }} | |
| className="hidden" | |
| id="pdf-upload" | |
| disabled={!canEdit} | |
| /> | |
| <label htmlFor="pdf-upload" className={cn("w-full h-full block", canEdit ? "cursor-pointer" : "cursor-not-allowed")}> | |
| <div className="w-16 h-16 bg-slate-800 rounded-full flex items-center justify-center mx-auto mb-4 group-hover:scale-110 transition-transform"> | |
| <Upload className="h-8 w-8 text-slate-400 group-hover:text-blue-400 transition-colors" /> | |
| </div> | |
| <p className="text-lg font-medium text-slate-300 mb-2">Click to upload PDF</p> | |
| <p className="text-sm text-slate-500">Maximum file size 10MB</p> | |
| </label> | |
| {content && ( | |
| <div className="absolute top-4 right-4 flex items-center gap-2 text-green-400 text-xs bg-green-500/10 py-1.5 px-3 rounded-full border border-green-500/20"> | |
| <CheckCircle className="h-3 w-3" /> | |
| <span>Ready</span> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| <Button | |
| type="submit" | |
| size="lg" | |
| className="w-full h-16 text-lg font-semibold bg-gradient-to-r from-blue-600 to-violet-600 hover:from-blue-500 hover:to-violet-500 text-white shadow-lg shadow-blue-500/25 rounded-xl transition-all hover:scale-[1.02] active:scale-[0.98]" | |
| disabled={isGenerating || !content || !canGenerate} | |
| > | |
| {isGenerating ? ( | |
| <div className="flex items-center gap-3"> | |
| <Loader2 className="h-6 w-6 animate-spin" /> | |
| <span>Processing Request...</span> | |
| </div> | |
| ) : ( | |
| <div className="flex items-center gap-3"> | |
| <Play className="h-6 w-6 fill-current" /> | |
| <span>Generate Animation</span> | |
| </div> | |
| )} | |
| </Button> | |
| </form> | |
| </div> | |
| {/* Right Column: Preview & Status */} | |
| <div className="lg:col-span-5 space-y-8"> | |
| <div> | |
| <h2 className="text-2xl font-bold text-white mb-2">Live Preview</h2> | |
| <p className="text-slate-400">Real-time generation status and output.</p> | |
| </div> | |
| <div className="sticky top-8"> | |
| <AnimatePresence mode="wait"> | |
| {currentJob ? ( | |
| <motion.div | |
| initial={{ opacity: 0, y: 20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| exit={{ opacity: 0, y: -20 }} | |
| className="space-y-6" | |
| > | |
| <Card className="bg-slate-900 border-white/10 overflow-hidden shadow-2xl"> | |
| <CardHeader className="border-b border-white/5 bg-slate-950/30 py-4"> | |
| <div className="flex items-center justify-between"> | |
| <CardTitle className="text-sm font-medium text-slate-300 flex items-center gap-2"> | |
| {currentJob.status === "completed" ? ( | |
| <span className="flex items-center gap-2 text-green-400"> | |
| <CheckCircle className="h-4 w-4" /> Completed | |
| </span> | |
| ) : currentJob.status === "failed" ? ( | |
| <span className="flex items-center gap-2 text-red-400"> | |
| <AlertCircle className="h-4 w-4" /> Failed | |
| </span> | |
| ) : ( | |
| <span className="flex items-center gap-2 text-blue-400"> | |
| <Loader2 className="h-4 w-4 animate-spin" /> Processing | |
| </span> | |
| )} | |
| </CardTitle> | |
| <div className="text-xs font-mono text-slate-500">{currentJob.job_id.slice(0, 8)}</div> | |
| </div> | |
| </CardHeader> | |
| <CardContent className="p-0"> | |
| {/* Video Player or Progress State */} | |
| <div className="aspect-video bg-black relative group"> | |
| {currentJob.status === "completed" ? ( | |
| <video | |
| key={demoVideoUrl || currentJob.job_id} | |
| src={demoVideoUrl || api.getVideoUrl(currentJob.job_id)} | |
| controls | |
| className="w-full h-full" | |
| poster="/placeholder-video.jpg" | |
| /> | |
| ) : ( | |
| <div className="absolute inset-0 flex flex-col items-center justify-center p-8 text-center"> | |
| <div className="relative w-24 h-24 mb-6"> | |
| <div className="absolute inset-0 rounded-full border-4 border-slate-800"></div> | |
| <div className="absolute inset-0 rounded-full border-4 border-t-blue-500 border-r-transparent border-b-transparent border-l-transparent animate-spin"></div> | |
| <div className="absolute inset-4 rounded-full bg-slate-800/50 backdrop-blur flex items-center justify-center"> | |
| <span className="text-sm font-bold text-white">{currentJob.progress.percentage}%</span> | |
| </div> | |
| </div> | |
| <h3 className="text-lg font-medium text-white mb-2">Generating Animation</h3> | |
| <p className="text-sm text-slate-400 max-w-xs mx-auto animate-pulse"> | |
| {currentJob.progress.message} | |
| </p> | |
| </div> | |
| )} | |
| </div> | |
| {/* Actions */} | |
| {currentJob.status === "completed" && ( | |
| <div className="p-4 bg-slate-900 border-t border-white/5"> | |
| <Button className="w-full bg-white text-slate-900 hover:bg-slate-200 font-medium" asChild> | |
| <a href={demoVideoUrl || api.getVideoUrl(currentJob.job_id)} download> | |
| <Download className="mr-2 h-4 w-4" /> | |
| Download MP4 (1080p) | |
| </a> | |
| </Button> | |
| </div> | |
| )} | |
| {/* Error Message */} | |
| {error && ( | |
| <div className="p-4 bg-red-500/10 border-t border-red-500/20"> | |
| <p className="text-sm text-red-400 flex items-start gap-2"> | |
| <AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" /> | |
| {error} | |
| </p> | |
| </div> | |
| )} | |
| </CardContent> | |
| </Card> | |
| {/* Process Steps (Visual Decoration) */} | |
| {currentJob.status !== "completed" && currentJob.status !== "failed" && ( | |
| <div className="space-y-3"> | |
| {["Analyzing Input", "Generating Script", "Validating Code", "Rendering Frames"].map((step, i) => { | |
| const currentStepIndex = Math.floor((currentJob.progress.percentage / 100) * 4); | |
| const isActive = i === currentStepIndex; | |
| const isCompleted = i < currentStepIndex; | |
| return ( | |
| <div key={step} className="flex items-center gap-3 text-sm"> | |
| <div className={cn( | |
| "w-6 h-6 rounded-full flex items-center justify-center border transition-colors", | |
| isCompleted ? "bg-green-500 border-green-500 text-slate-900" : | |
| isActive ? "border-blue-500 text-blue-500" : "border-slate-700 text-slate-700" | |
| )}> | |
| {isCompleted ? <CheckCircle className="w-4 h-4" /> : <div className={cn("w-2 h-2 rounded-full", isActive ? "bg-blue-500 animate-pulse" : "bg-slate-700")} />} | |
| </div> | |
| <span className={cn( | |
| "transition-colors", | |
| isCompleted ? "text-slate-300" : | |
| isActive ? "text-white font-medium" : "text-slate-600" | |
| )}>{step}</span> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| </motion.div> | |
| ) : ( | |
| <div className="h-[400px] rounded-2xl border-2 border-dashed border-slate-800 bg-slate-900/30 flex flex-col items-center justify-center text-slate-500 p-8 text-center"> | |
| <div className="w-20 h-20 rounded-full bg-slate-800/50 flex items-center justify-center mb-6"> | |
| <Video className="h-10 w-10 opacity-50" /> | |
| </div> | |
| <h3 className="text-lg font-medium text-slate-300 mb-2">Ready to Generate</h3> | |
| <p className="max-w-xs text-sm"> | |
| Configure your animation parameters on the left and click generate to see the magic happen. | |
| </p> | |
| </div> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| </div> | |
| </div> | |
| ); | |
| } | |