654 lines
25 KiB
TypeScript
654 lines
25 KiB
TypeScript
/**
|
|
* 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<ImageQueueItem[]>(queue);
|
|
// Track smooth progress animation for each item
|
|
const [smoothProgress, setSmoothProgress] = useState<Record<number, number>>({});
|
|
const progressIntervalsRef = useRef<Record<number, ReturnType<typeof setInterval>>>({});
|
|
|
|
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 <TimeIcon className="w-4 h-4" />;
|
|
case 'processing':
|
|
return (
|
|
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
);
|
|
case 'completed':
|
|
return <CheckCircleIcon className="w-4 h-4" />;
|
|
case 'failed':
|
|
return <ErrorIcon className="w-4 h-4" />;
|
|
default:
|
|
return <TimeIcon className="w-4 h-4" />;
|
|
}
|
|
};
|
|
|
|
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 (
|
|
<Modal
|
|
isOpen={isOpen}
|
|
onClose={onClose}
|
|
className="max-w-4xl w-full max-h-[80vh] overflow-hidden flex flex-col"
|
|
showCloseButton={!isProcessing}
|
|
>
|
|
{/* Header */}
|
|
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<FileIcon className="w-6 h-6 text-blue-500" />
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
|
Generating Images
|
|
</h3>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
|
Total: {totalImages} image{totalImages !== 1 ? 's' : ''} in queue
|
|
</p>
|
|
{model && (
|
|
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
|
Model: {provider === 'openai' ? 'OpenAI' : provider === 'runware' ? 'Runware' : provider || 'Unknown'} {model === 'dall-e-2' ? 'DALL·E 2' : model === 'dall-e-3' ? 'DALL·E 3' : model}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Queue List */}
|
|
<div className="flex-1 overflow-y-auto px-6 py-4">
|
|
<div className="space-y-3">
|
|
{localQueue.map((item) => (
|
|
<div
|
|
key={item.index}
|
|
className={`p-4 rounded-lg border-2 transition-colors ${
|
|
item.status === 'processing'
|
|
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-500'
|
|
: item.status === 'completed'
|
|
? 'bg-green-50 dark:bg-green-900/20 border-green-500'
|
|
: item.status === 'failed'
|
|
? 'bg-red-50 dark:bg-red-900/20 border-red-500'
|
|
: 'bg-gray-50 dark:bg-gray-700/50 border-gray-300 dark:border-gray-600'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-4">
|
|
{/* Left: Queue Info */}
|
|
<div className="flex-1">
|
|
{/* Header Row */}
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-blue-500 text-white text-xs font-bold">
|
|
{item.index}
|
|
</span>
|
|
<span className="font-semibold text-sm text-gray-900 dark:text-white">
|
|
{item.label}
|
|
</span>
|
|
<span className="flex-1 text-xs text-gray-500 dark:text-gray-400 truncate">
|
|
{item.prompt
|
|
? (() => {
|
|
const words = item.prompt.split(' ');
|
|
const firstTenWords = words.slice(0, 10).join(' ');
|
|
return words.length > 10 ? `${firstTenWords}...` : item.prompt;
|
|
})()
|
|
: item.contentTitle}
|
|
</span>
|
|
<span className="text-xs font-semibold text-gray-600 dark:text-gray-300 whitespace-nowrap flex items-center gap-1">
|
|
{getStatusIcon(item.status)}
|
|
<span>{getStatusText(item.status)}</span>
|
|
</span>
|
|
</div>
|
|
|
|
{/* Progress Bar - Use smooth progress for visual display */}
|
|
<div className="relative h-5 bg-gray-200 dark:bg-gray-600 rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full ${getProgressColor(item.status)} transition-all duration-150 ease-out`}
|
|
style={{ width: `${smoothProgress[item.index] ?? item.progress ?? 0}%` }}
|
|
/>
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<span
|
|
className={`text-xs font-bold ${
|
|
(smoothProgress[item.index] ?? item.progress ?? 0) >= 50
|
|
? 'text-white'
|
|
: 'text-gray-700 dark:text-gray-200'
|
|
}`}
|
|
>
|
|
{smoothProgress[item.index] ?? item.progress ?? 0}%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Error Message */}
|
|
{item.error && (
|
|
<div className="mt-2 p-2 bg-red-100 dark:bg-red-900/30 border-l-4 border-red-500 rounded text-xs text-red-700 dark:text-red-300">
|
|
{item.error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right: Thumbnail - Only show if image is completed and saved */}
|
|
<div className="w-20 h-20 bg-gray-100 dark:bg-gray-700 rounded-lg overflow-hidden flex-shrink-0 flex items-center justify-center">
|
|
{item.status === 'completed' && item.imageUrl ? (
|
|
<img
|
|
src={item.imageUrl}
|
|
alt={item.label}
|
|
className="w-full h-full object-cover"
|
|
onError={(e) => {
|
|
// 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 = '<span class="text-xs text-gray-400 dark:text-gray-500">No image</span>';
|
|
}
|
|
}}
|
|
/>
|
|
) : (
|
|
<span className="text-xs text-gray-400 dark:text-gray-500">
|
|
No image
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
|
<div className="flex items-center justify-between">
|
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
|
{completedCount} completed{failedCount > 0 ? `, ${failedCount} failed` : ''} of {totalImages} total
|
|
</div>
|
|
{allDone && (
|
|
<button
|
|
onClick={onClose}
|
|
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
|
>
|
|
Close
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|