clipon / frontend /src /components /ProcessingView.jsx
yonagush
Fix: 404 job polling, stale closure, brand kit UI, article→reels feature
b5d56b9
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'
/**
* Processing view with progress indicators
* BUG FIX: data.result doesn't exist — backend returns full JobResult.
* BUG FIX: Added polling fallback (every 2.5s) when WebSocket drops.
* Props: { jobId, jobType, onComplete }
*/
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)
// BUG FIX: pass `data` (the full JobResult), NOT `data.result`
onComplete(data)
}
if (data.status === 'failed') {
clearInterval(pollingRef.current)
setError(data.error || 'Processing failed. Please try again.')
}
}
// WebSocket updates
useEffect(() => {
if (jobData) handleJobData(jobData)
}, [jobData]) // eslint-disable-line
// Polling fallback — fires every 2.5s regardless of WebSocket
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]) // eslint-disable-line
// Map backend status string → step index
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