/** * ImageQueueModal - Displays image generation queue with individual progress bars * Similar to WP plugin's image-queue-processor.js modal * Stage 1: Shows all progress bars immediately when Generate button is clicked */ import React, { useEffect, useState, useRef } from 'react'; import { Modal } from '../ui/modal'; import { FileIcon, TimeIcon, CheckCircleIcon, ErrorIcon } from '../../icons'; import { fetchAPI } from '../../services/api'; export interface ImageQueueItem { imageId: number | null; index: number; label: string; type: 'featured' | 'in_article'; position?: number; contentTitle: string; prompt?: string; // Image prompt text status: 'pending' | 'processing' | 'completed' | 'failed'; progress: number; imageUrl: string | null; error: string | null; } interface ImageQueueModalProps { isOpen: boolean; onClose: () => void; queue: ImageQueueItem[]; totalImages: number; taskId?: string | null; model?: string; provider?: string; onUpdateQueue?: (queue: ImageQueueItem[]) => void; onLog?: (log: { timestamp: string; type: 'request' | 'success' | 'error' | 'step'; action: string; data: any; stepName?: string; percentage?: number; }) => void; } export default function ImageQueueModal({ isOpen, onClose, queue, totalImages, taskId, model, provider, onUpdateQueue, onLog, }: ImageQueueModalProps) { const [localQueue, setLocalQueue] = useState(queue); // Track smooth progress animation for each item const [smoothProgress, setSmoothProgress] = useState>({}); const progressIntervalsRef = useRef>>({}); useEffect(() => { setLocalQueue(queue); }, [queue]); useEffect(() => { if (onUpdateQueue) { onUpdateQueue(localQueue); } }, [localQueue, onUpdateQueue]); // Time-based progress: 1% → 50% in 5s, then 2% every 200ms until 80%, then event-based to 100% useEffect(() => { // Helper function for Phase 2: 2% every 200ms until 80% const startPhase2Animation = (index: number, startProgress: number) => { if (progressIntervalsRef.current[index]) { clearInterval(progressIntervalsRef.current[index]); delete progressIntervalsRef.current[index]; } let currentPercent = Math.max(startProgress, 50); const interval = setInterval(() => { setSmoothProgress(prev => { const current = prev[index] ?? currentPercent; // Only increment if still below 80% and item is actively processing const item = localQueue.find(item => item.index === index); if (current < 80 && item?.status === 'processing') { const newPercentage = Math.min(current + 2, 80); // 2% every 200ms currentPercent = newPercentage; // Stop if we've reached 80% if (newPercentage >= 80) { clearInterval(interval); delete progressIntervalsRef.current[index]; } return { ...prev, [index]: newPercentage }; } else { // Stop if status changed or reached 80% clearInterval(interval); delete progressIntervalsRef.current[index]; return prev; } }); }, 200); // 2% every 200ms progressIntervalsRef.current[index] = interval; }; const indicesToClear: number[] = []; localQueue.forEach((item) => { if (item.status === 'processing') { const currentProgress = smoothProgress[item.index] ?? (item.progress > 0 ? item.progress : 1); if (currentProgress < 50 && !progressIntervalsRef.current[item.index]) { let currentPercent = Math.max(currentProgress, 1); const interval = setInterval(() => { setSmoothProgress(prev => { const current = prev[item.index] ?? currentPercent; if (current < 50 && item.status === 'processing') { const newPercentage = Math.min(current + 1, 50); currentPercent = newPercentage; if (newPercentage >= 50) { clearInterval(interval); delete progressIntervalsRef.current[item.index]; if (newPercentage < 80) { startPhase2Animation(item.index, newPercentage); } } return { ...prev, [item.index]: newPercentage }; } else { clearInterval(interval); delete progressIntervalsRef.current[item.index]; return prev; } }); }, 200); progressIntervalsRef.current[item.index] = interval; } else if (currentProgress >= 50 && currentProgress < 80 && !progressIntervalsRef.current[item.index]) { startPhase2Animation(item.index, currentProgress); } else if (item.imageUrl && currentProgress < 100 && !progressIntervalsRef.current[item.index]) { if (progressIntervalsRef.current[item.index]) { clearInterval(progressIntervalsRef.current[item.index]); delete progressIntervalsRef.current[item.index]; } let animatingProgress = Math.max(currentProgress, 80); const startProgress = animatingProgress; const endProgress = 100; const duration = 800; const startTime = Date.now(); const interval = setInterval(() => { const elapsed = Date.now() - startTime; const progress = Math.min(1, elapsed / duration); const eased = 1 - Math.pow(1 - progress, 2); animatingProgress = startProgress + ((endProgress - startProgress) * eased); if (animatingProgress >= 100 || elapsed >= duration) { animatingProgress = 100; clearInterval(interval); delete progressIntervalsRef.current[item.index]; } setSmoothProgress(prev => ({ ...prev, [item.index]: Math.round(animatingProgress) })); }, 16); progressIntervalsRef.current[item.index] = interval; } } else { if (progressIntervalsRef.current[item.index]) { clearInterval(progressIntervalsRef.current[item.index]); delete progressIntervalsRef.current[item.index]; } if (smoothProgress[item.index] !== undefined) { indicesToClear.push(item.index); } } }); if (indicesToClear.length > 0) { setSmoothProgress(prev => { const next = { ...prev }; indicesToClear.forEach(idx => { delete next[idx]; }); return next; }); } // Cleanup on unmount return () => { Object.values(progressIntervalsRef.current).forEach(interval => clearInterval(interval)); progressIntervalsRef.current = {}; }; }, [localQueue, smoothProgress]); // Polling for task status updates useEffect(() => { if (!isOpen || !taskId) return; let pollAttempts = 0; const maxPollAttempts = 300; // 5 minutes max (300 * 1 second) const pollInterval = setInterval(async () => { pollAttempts++; // Stop polling after max attempts if (pollAttempts > maxPollAttempts) { console.warn('Polling timeout reached, stopping'); clearInterval(pollInterval); return; } try { console.log(`[ImageQueueModal] Polling task status (attempt ${pollAttempts}):`, taskId); const data = await fetchAPI(`/v1/system/settings/task_progress/${taskId}/`); console.log(`[ImageQueueModal] Task status response:`, data); // Check if data is valid (not HTML error page) if (!data || typeof data !== 'object') { console.warn('Invalid task status response:', data); return; } // Check state (task_progress returns 'state', not 'status') const taskState = data.state || data.status; console.log(`[ImageQueueModal] Task state:`, taskState); if (taskState === 'SUCCESS' || taskState === 'FAILURE') { console.log(`[ImageQueueModal] Task completed with state:`, taskState); clearInterval(pollInterval); // Log completion status if (onLog) { if (taskState === 'SUCCESS') { const result = data.result || (data.meta && data.meta.result); const completed = result?.completed || 0; const failed = result?.failed || 0; const total = result?.total_images || totalImages; onLog({ timestamp: new Date().toISOString(), type: failed > 0 ? 'error' : 'success', action: 'generate_images', stepName: 'Task Completed', data: { state: 'SUCCESS', completed, failed, total, results: result?.results || [] } }); } else { // FAILURE onLog({ timestamp: new Date().toISOString(), type: 'error', action: 'generate_images', stepName: 'Task Failed', data: { state: 'FAILURE', error: data.error || data.meta?.error || 'Task failed', meta: data.meta } }); } } // Update final state if (taskState === 'SUCCESS' && data.result) { console.log(`[ImageQueueModal] Updating queue from result:`, data.result); updateQueueFromTaskResult(data.result); } else if (taskState === 'SUCCESS' && data.meta && data.meta.result) { // Some responses have result in meta console.log(`[ImageQueueModal] Updating queue from meta result:`, data.meta.result); updateQueueFromTaskResult(data.meta.result); } return; } // Update progress from task meta if (data.meta) { console.log(`[ImageQueueModal] Updating queue from meta:`, data.meta); updateQueueFromTaskMeta(data.meta); } else { console.log(`[ImageQueueModal] No meta data in response`); } } catch (error: any) { // Check if it's a JSON parse error (HTML response) or API error if (error.message && (error.message.includes('JSON') || error.message.includes('API Error'))) { console.error('Task status endpoint error:', { message: error.message, status: error.status, taskId: taskId, endpoint: `/v1/system/settings/task_progress/${taskId}/`, error: error }); // If it's a 404, the endpoint might not exist - stop polling after a few attempts if (error.status === 404) { console.error('Task progress endpoint not found (404). Stopping polling.'); clearInterval(pollInterval); return; } // Don't stop polling for other errors, but log them } else { console.error('Error polling task status:', error); } } }, 1000); // Poll every second return () => clearInterval(pollInterval); }, [isOpen, taskId]); // Helper function to convert image_path to frontend-accessible URL const getImageUrlFromPath = (imagePath: string | null | undefined): string | null => { if (!imagePath) return null; // If path contains 'ai-images', extract filename and convert to web URL if (imagePath.includes('ai-images')) { const filename = imagePath.split('ai-images/')[1] || imagePath.split('ai-images\\')[1]; if (filename) { return `/images/ai-images/${filename}`; } } // If path is already a web path, return as-is if (imagePath.startsWith('/images/')) { return imagePath; } // Otherwise, try to extract filename and use ai-images path const filename = imagePath.split('/').pop() || imagePath.split('\\').pop(); return filename ? `/images/ai-images/${filename}` : null; }; const updateQueueFromTaskMeta = (meta: any) => { const { current_image, total_images, completed, failed, results, current_image_progress, current_image_id } = meta; setLocalQueue(prevQueue => { return prevQueue.map((item, index) => { const result = results?.find((r: any) => r.image_id === item.imageId); if (result) { // Stop smooth animation for completed/failed items if (result.status === 'completed' || result.status === 'failed') { if (progressIntervalsRef.current[item.index]) { clearInterval(progressIntervalsRef.current[item.index]); delete progressIntervalsRef.current[item.index]; } } // Only set imageUrl if image is completed AND image_path exists (image is saved) const imageUrl = (result.status === 'completed' && result.image_path) ? getImageUrlFromPath(result.image_path) : null; // Use backend progress as target (0%, 50%, 75%, 90%, 100%) // This works for both featured and in-article images const backendProgress = (current_image_id === item.imageId && current_image_progress !== undefined) ? current_image_progress : (result.status === 'completed' ? 100 : result.status === 'failed' ? 0 : item.progress); return { ...item, status: result.status === 'completed' ? 'completed' : result.status === 'failed' ? 'failed' : 'processing', progress: backendProgress, // Use backend progress as target for smooth animation imageUrl: imageUrl || item.imageUrl, // Only update if we have a new URL, otherwise keep existing error: result.error || null }; } // Update based on current_image index and progress if (index + 1 < current_image) { // Already completed - stop animation if (progressIntervalsRef.current[item.index]) { clearInterval(progressIntervalsRef.current[item.index]); delete progressIntervalsRef.current[item.index]; } return { ...item, status: 'completed', progress: 100 }; } // SAFE: Only change to 'processing' when backend confirms with actual image ID // This ensures progress bar only moves when actual processing starts // Works consistently for both featured and in-article images else if (current_image_id === item.imageId) { // Currently processing - use backend progress if available const backendProgress = (current_image_progress !== undefined) ? current_image_progress : (smoothProgress[item.index] ?? 0); return { ...item, status: 'processing', progress: backendProgress }; } // Keep as 'pending' until backend confirms processing with image ID return item; }); }); }; const updateQueueFromTaskResult = (result: any) => { const { results } = result; setLocalQueue(prevQueue => { return prevQueue.map((item) => { const taskResult = results?.find((r: any) => r.image_id === item.imageId); if (taskResult) { // Stop smooth animation if (progressIntervalsRef.current[item.index]) { clearInterval(progressIntervalsRef.current[item.index]); delete progressIntervalsRef.current[item.index]; } // Only set imageUrl if image is completed AND image_path exists (image is saved) const imageUrl = (taskResult.status === 'completed' && taskResult.image_path) ? getImageUrlFromPath(taskResult.image_path) : null; return { ...item, status: taskResult.status === 'completed' ? 'completed' : 'failed', progress: taskResult.status === 'completed' ? 100 : 0, imageUrl: imageUrl || item.imageUrl, // Only update if we have a new URL, otherwise keep existing error: taskResult.error || null }; } return item; }); }); }; if (!isOpen) return null; const getStatusIcon = (status: string) => { switch (status) { case 'pending': return ; case 'processing': return ( ); case 'completed': return ; case 'failed': return ; default: return ; } }; const getStatusText = (status: string) => { switch (status) { case 'pending': return 'Pending'; case 'processing': return 'Generating...'; case 'completed': return 'Complete'; case 'failed': return 'Failed'; default: return 'Pending'; } }; const getProgressColor = (status: string) => { switch (status) { case 'completed': return 'bg-green-500'; case 'failed': return 'bg-red-500'; case 'processing': return 'bg-blue-500'; default: return 'bg-gray-300'; } }; const isProcessing = localQueue.some(item => item.status === 'processing'); const completedCount = localQueue.filter(item => item.status === 'completed').length; const failedCount = localQueue.filter(item => item.status === 'failed').length; const allDone = localQueue.every(item => item.status === 'completed' || item.status === 'failed'); return ( {/* Header */}

Generating Images

Total: {totalImages} image{totalImages !== 1 ? 's' : ''} in queue

{model && (

Model: {provider === 'openai' ? 'OpenAI' : provider === 'runware' ? 'Runware' : provider || 'Unknown'} {model === 'dall-e-2' ? 'DALL·E 2' : model === 'dall-e-3' ? 'DALL·E 3' : model}

)}
{/* Queue List */}
{localQueue.map((item) => (
{/* Left: Queue Info */}
{/* Header Row */}
{item.index} {item.label} {item.prompt ? (() => { const words = item.prompt.split(' '); const firstTenWords = words.slice(0, 10).join(' '); return words.length > 10 ? `${firstTenWords}...` : item.prompt; })() : item.contentTitle} {getStatusIcon(item.status)} {getStatusText(item.status)}
{/* Progress Bar - Use smooth progress for visual display */}
= 50 ? 'text-white' : 'text-gray-700 dark:text-gray-200' }`} > {smoothProgress[item.index] ?? item.progress ?? 0}%
{/* Error Message */} {item.error && (
{item.error}
)}
{/* Right: Thumbnail - Only show if image is completed and saved */}
{item.status === 'completed' && item.imageUrl ? ( {item.label} { // Hide image if it fails to load (not yet available) const target = e.target as HTMLImageElement; target.style.display = 'none'; const parent = target.parentElement; if (parent) { parent.innerHTML = 'No image'; } }} /> ) : ( No image )}
))}
{/* Footer */}
{completedCount} completed{failedCount > 0 ? `, ${failedCount} failed` : ''} of {totalImages} total
{allDone && ( )}
); }