| import { useEffect, useState, useRef } from 'react' |
| import { CheckCircle2, Circle, Zap, Film, FileText } from 'lucide-react' |
| import { useWebSocket } from '../hooks/useWebSocket' |
| import { getJob } from '../utils/api' |
|
|
| |
| |
| |
| |
| |
| |
| function ProcessingView({ jobId, jobType = 'clip', onComplete }) { |
| const { jobData } = useWebSocket(jobId) |
| const [jobInfo, setJobInfo] = useState(null) |
| const [error, setError] = useState(null) |
| const pollingRef = useRef(null) |
| const completedRef = useRef(false) |
|
|
| const clipSteps = [ |
| { id: 'downloading', label: 'Downloading' }, |
| { id: 'transcribing', label: 'Transcribing Audio' }, |
| { id: 'analyzing', label: 'Analyzing Clips' }, |
| { id: 'editing', label: 'Editing Video' }, |
| { id: 'captioning', label: 'Adding Captions' }, |
| { id: 'completed', label: 'Done' }, |
| ] |
| const articleSteps = [ |
| { id: 'processing', label: 'Scraping Article' }, |
| { id: 'processing', label: 'Generating Script' }, |
| { id: 'processing', label: 'Creating Voiceover' }, |
| { id: 'processing', label: 'Assembling Reel' }, |
| { id: 'completed', label: 'Done' }, |
| ] |
| const steps = jobType === 'article' ? articleSteps : clipSteps |
|
|
| const handleJobData = (data) => { |
| if (!data) return |
| setJobInfo(data) |
|
|
| if (data.status === 'completed' && !completedRef.current) { |
| completedRef.current = true |
| clearInterval(pollingRef.current) |
| |
| onComplete(data) |
| } |
| if (data.status === 'failed') { |
| clearInterval(pollingRef.current) |
| setError(data.error || 'Processing failed. Please try again.') |
| } |
| } |
|
|
| |
| useEffect(() => { |
| if (jobData) handleJobData(jobData) |
| }, [jobData]) |
|
|
| |
| useEffect(() => { |
| if (!jobId) return |
| pollingRef.current = setInterval(async () => { |
| try { |
| const data = await getJob(jobId) |
| handleJobData(data) |
| } catch (err) { |
| console.warn('[poll]', err.message) |
| } |
| }, 2500) |
|
|
| return () => clearInterval(pollingRef.current) |
| }, [jobId]) |
|
|
| |
| const statusToStep = { |
| queued: 0, downloading: 0, transcribing: 1, |
| analyzing: 2, editing: 3, captioning: 4, completed: 5, failed: 0, |
| } |
| const currentStepIndex = statusToStep[jobInfo?.status] ?? 0 |
| const progressPct = jobInfo?.progress ?? Math.round(((currentStepIndex + 1) / steps.length) * 100) |
|
|
| return ( |
| <div className="min-h-screen flex flex-col items-center justify-center px-4 py-20"> |
| <div className="max-w-2xl w-full"> |
| {/* Header */} |
| <div className="text-center mb-12"> |
| <div className="flex justify-center mb-4"> |
| {jobType === 'article' |
| ? <FileText className="w-12 h-12 text-primary-500" /> |
| : <Film className="w-12 h-12 text-primary-500" /> |
| } |
| </div> |
| <h2 className="text-4xl font-bold text-white mb-2"> |
| {jobType === 'article' ? 'Creating Your Reels' : 'Creating Your Clips'} |
| </h2> |
| <p className="text-white/60"> |
| {jobType === 'article' |
| ? 'Turning the article into viral social content…' |
| : 'Hold tight, AI magic in progress…'} |
| </p> |
| </div> |
| |
| {/* Main Card */} |
| <div className="glass-lg p-8 rounded-3xl mb-8"> |
| {/* Source */} |
| {jobInfo?.source_url && ( |
| <div className="mb-8 pb-8 border-b border-white/10"> |
| <p className="text-sm text-white/60 mb-1">Processing</p> |
| <p className="text-base font-semibold text-white truncate">{jobInfo.source_url}</p> |
| </div> |
| )} |
| |
| {/* Progress Bar */} |
| <div className="mb-8"> |
| <div className="flex justify-between items-center mb-2"> |
| <span className="text-sm font-semibold text-white">Overall Progress</span> |
| <span className="text-sm font-semibold text-primary-500">{progressPct}%</span> |
| </div> |
| <div className="h-2 bg-white/10 rounded-full overflow-hidden"> |
| <div |
| className="h-full progress-gradient transition-all duration-700" |
| style={{ width: `${progressPct}%` }} |
| /> |
| </div> |
| {jobInfo?.message && ( |
| <p className="text-xs text-white/50 mt-2 italic">{jobInfo.message}</p> |
| )} |
| </div> |
| |
| {/* Steps */} |
| <div className="space-y-3"> |
| {steps.map((step, idx) => { |
| const done = idx < currentStepIndex |
| const active = idx === currentStepIndex |
| |
| return ( |
| <div |
| key={`${step.id}-${idx}`} |
| className={`flex items-center gap-4 p-4 rounded-xl transition-all ${ |
| active ? 'bg-primary-500/10 border border-primary-500/30' |
| : done ? 'bg-white/5' |
| : 'bg-white/5 opacity-40' |
| }`} |
| > |
| <div className="flex-shrink-0"> |
| {done ? ( |
| <CheckCircle2 className="w-6 h-6 text-green-400" /> |
| ) : active ? ( |
| <div className="w-6 h-6 rounded-full border-2 border-transparent border-t-primary-500 border-r-primary-500 animate-spin" /> |
| ) : ( |
| <Circle className="w-6 h-6 text-white/30" /> |
| )} |
| </div> |
| <span className={`font-semibold ${active ? 'text-white' : 'text-white/60'}`}> |
| {step.label} |
| </span> |
| {active && ( |
| <div className="ml-auto flex gap-1"> |
| {[0, 0.1, 0.2].map((delay, i) => ( |
| <span |
| key={i} |
| className="w-1.5 h-1.5 rounded-full bg-primary-500 animate-bounce" |
| style={{ animationDelay: `${delay}s` }} |
| /> |
| ))} |
| </div> |
| )} |
| </div> |
| ) |
| })} |
| </div> |
| </div> |
| |
| {/* Tip */} |
| <div className="glass p-6 rounded-2xl"> |
| <div className="flex items-start gap-3"> |
| <Zap className="w-5 h-5 text-primary-500 mt-1 flex-shrink-0" /> |
| <div> |
| <p className="text-sm font-semibold text-white mb-1"> |
| {jobType === 'article' ? 'Article → Reel' : 'Pro Tip'} |
| </p> |
| <p className="text-sm text-white/60"> |
| {jobType === 'article' |
| ? 'Your article is being condensed into a punchy ~45-second reel with AI voiceover and a chainstreet.io call-to-action.' |
| : 'ClipCraft AI analyzes emotional beats, engagement patterns, and viewer retention to find the perfect viral moments.'} |
| </p> |
| </div> |
| </div> |
| </div> |
| |
| {error && ( |
| <div className="mt-6 p-4 rounded-lg bg-red-500/10 border border-red-500/30 text-red-400 text-sm"> |
| {error} |
| </div> |
| )} |
| </div> |
| </div> |
| ) |
| } |
|
|
| export default ProcessingView |
|
|