/** * Images Page - Built with TablePageTemplate * Shows content images grouped by content - one row per content */ import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import TablePageTemplate from '../../templates/TablePageTemplate'; import { fetchContentImages, ContentImagesGroup, ContentImagesResponse, fetchImageGenerationSettings, generateImages, bulkUpdateImagesStatus, ContentImage, api, } from '../../services/api'; import { useToast } from '../../components/ui/toast/ToastContainer'; import { FileIcon, DownloadIcon, BoltIcon, TaskIcon, ImageIcon, CheckCircleIcon } from '../../icons'; import { createImagesPageConfig } from '../../config/pages/images.config'; import ImageQueueModal, { ImageQueueItem } from '../../components/common/ImageQueueModal'; import SingleRecordStatusUpdateModal from '../../components/common/SingleRecordStatusUpdateModal'; import { useResourceDebug } from '../../hooks/useResourceDebug'; import PageHeader from '../../components/common/PageHeader'; import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs'; import { Modal } from '../../components/ui/modal'; export default function Images() { const toast = useToast(); // Resource Debug toggle - controls AI Function Logs const resourceDebugEnabled = useResourceDebug(); // AI Function Logs state const [aiLogs, setAiLogs] = useState>([]); // Helper function to add log entry (only if Resource Debug is enabled) const addAiLog = useCallback((log: { timestamp: string; type: 'request' | 'success' | 'error' | 'step'; action: string; data: any; stepName?: string; percentage?: number; }) => { if (resourceDebugEnabled) { setAiLogs(prev => [...prev, log]); } }, [resourceDebugEnabled]); // Data state const [images, setImages] = useState([]); const [loading, setLoading] = useState(true); // Filter state const [searchTerm, setSearchTerm] = useState(''); const [statusFilter, setStatusFilter] = useState(''); const [selectedIds, setSelectedIds] = useState([]); // Pagination state (client-side for now) const [currentPage, setCurrentPage] = useState(1); const [totalPages, setTotalPages] = useState(1); const [totalCount, setTotalCount] = useState(0); const pageSize = 10; // Sorting state const [sortBy, setSortBy] = useState('content_title'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const [showContent, setShowContent] = useState(false); // Image queue modal state const [isQueueModalOpen, setIsQueueModalOpen] = useState(false); const [imageQueue, setImageQueue] = useState([]); const [currentContentId, setCurrentContentId] = useState(null); const [taskId, setTaskId] = useState(null); const [imageModel, setImageModel] = useState(null); const [imageProvider, setImageProvider] = useState(null); // Status update modal state const [isStatusModalOpen, setIsStatusModalOpen] = useState(false); const [statusUpdateContentId, setStatusUpdateContentId] = useState(null); const [statusUpdateRecordName, setStatusUpdateRecordName] = useState(''); const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); // Image modal state const [isImageModalOpen, setIsImageModalOpen] = useState(false); const [modalImageUrl, setModalImageUrl] = useState(null); // Load images - wrapped in useCallback const loadImages = useCallback(async () => { setLoading(true); setShowContent(false); try { const data: ContentImagesResponse = await fetchContentImages({}); let filteredResults = data.results || []; // Client-side search filter if (searchTerm) { filteredResults = filteredResults.filter(group => group.content_title?.toLowerCase().includes(searchTerm.toLowerCase()) ); } // Client-side status filter if (statusFilter) { filteredResults = filteredResults.filter(group => group.overall_status === statusFilter ); } // Client-side sorting filteredResults.sort((a, b) => { let aVal: any = a.content_title; let bVal: any = b.content_title; if (sortBy === 'overall_status') { aVal = a.overall_status; bVal = b.overall_status; } if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1; if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1; return 0; }); // Client-side pagination const startIndex = (currentPage - 1) * pageSize; const endIndex = startIndex + pageSize; const paginatedResults = filteredResults.slice(startIndex, endIndex); setImages(paginatedResults); setTotalCount(filteredResults.length); setTotalPages(Math.ceil(filteredResults.length / pageSize)); setTimeout(() => { setShowContent(true); setLoading(false); }, 100); } catch (error: any) { console.error('Error loading images:', error); toast.error(`Failed to load images: ${error.message}`); setShowContent(true); setLoading(false); } }, [currentPage, statusFilter, sortBy, sortDirection, searchTerm, toast]); useEffect(() => { loadImages(); }, [loadImages]); // Listen for site and sector changes and refresh data useEffect(() => { const handleSiteChange = () => { loadImages(); }; const handleSectorChange = () => { loadImages(); }; window.addEventListener('siteChanged', handleSiteChange); window.addEventListener('sectorChanged', handleSectorChange); return () => { window.removeEventListener('siteChanged', handleSiteChange); window.removeEventListener('sectorChanged', handleSectorChange); }; }, [loadImages]); // Debounced search useEffect(() => { const timer = setTimeout(() => { if (currentPage === 1) { loadImages(); } else { setCurrentPage(1); } }, 500); return () => clearTimeout(timer); }, [searchTerm, currentPage, loadImages]); // Handle sorting const handleSort = (field: string, direction: 'asc' | 'desc') => { setSortBy(field || 'content_title'); setSortDirection(direction); setCurrentPage(1); }; // Bulk export handler const handleBulkExport = useCallback(async (ids: string[]) => { try { if (!ids || ids.length === 0) { throw new Error('No records selected for export'); } toast.info('Export functionality coming soon'); } catch (error: any) { throw error; } }, [toast]); // Bulk action handler const handleBulkAction = useCallback(async (action: string, ids: string[]) => { if (action === 'bulk_publish_wordpress') { // Filter to only publish items that have images generated and are not already published const readyItems = images .filter(item => ids.includes(item.content_id.toString())) .filter(item => item.status === 'complete' && (!item.wordpress_status || (item.wordpress_status !== 'published' && item.wordpress_status !== 'publishing'))); if (readyItems.length === 0) { toast.warning('No items are ready for WordPress publishing. Items must have generated images and not already be published.'); return; } try { const response = await api.post('/api/wordpress/bulk-publish/', { content_ids: readyItems.map(item => item.content_id.toString()) }); if (response.data.success) { const results = response.data.data.results; const successCount = results.filter((r: any) => r.success).length; const failedCount = results.filter((r: any) => !r.success).length; if (successCount > 0) { toast.success(`Successfully published ${successCount} item(s) to WordPress`); } if (failedCount > 0) { toast.warning(`${failedCount} item(s) failed to publish`); } // Reload images to reflect the updated WordPress status loadImages(); } else { toast.error(`Bulk publish failed: ${response.data.message}`); } } catch (error: any) { console.error('Bulk WordPress publish error:', error); toast.error(`Failed to bulk publish to WordPress: ${error.message || 'Network error'}`); } } else { toast.info(`Bulk action "${action}" for ${ids.length} items`); } }, [images, toast, loadImages]); // Row action handler const handleRowAction = useCallback(async (action: string, row: ContentImagesGroup) => { if (action === 'update_status') { setStatusUpdateContentId(row.content_id); setStatusUpdateRecordName(row.content_title || `Content #${row.content_id}`); setIsStatusModalOpen(true); } else if (action === 'publish_wordpress') { // Handle WordPress publishing for individual item try { const response = await api.post('/api/wordpress/publish/', { content_id: row.content_id.toString() }); if (response.data.success) { toast.success(`Successfully published "${row.content_title}" to WordPress`); // Reload images to reflect the updated WordPress status loadImages(); } else { toast.error(`Failed to publish: ${response.data.message}`); } } catch (error: any) { console.error('WordPress publish error:', error); toast.error(`Failed to publish to WordPress: ${error.message || 'Network error'}`); } } }, [loadImages, toast]); // Handle status update confirmation const handleStatusUpdate = useCallback(async (status: string) => { if (!statusUpdateContentId) return; setIsUpdatingStatus(true); try { const result = await bulkUpdateImagesStatus(statusUpdateContentId, status); toast.success(`Successfully updated ${result.updated_count} image(s) status to ${status}`); setIsStatusModalOpen(false); setStatusUpdateContentId(null); setStatusUpdateRecordName(''); // Reload images to reflect the changes loadImages(); } catch (error: any) { toast.error(`Failed to update status: ${error.message}`); } finally { setIsUpdatingStatus(false); } }, [statusUpdateContentId, toast, loadImages]); // Build image queue structure const buildImageQueue = useCallback((contentId: number, maxInArticleImages: number) => { const contentImages = images.find(g => g.content_id === contentId); if (!contentImages) return []; const queue: ImageQueueItem[] = []; let queueIndex = 1; // Featured image (always first) if (contentImages.featured_image?.status === 'pending' && contentImages.featured_image?.prompt) { queue.push({ imageId: contentImages.featured_image.id || null, index: queueIndex++, label: 'Featured Image', type: 'featured', contentTitle: contentImages.content_title || `Content #${contentId}`, prompt: contentImages.featured_image.prompt, status: 'pending', progress: 0, imageUrl: null, error: null, }); } // In-article images (up to max_in_article_images) const pendingInArticle = contentImages.in_article_images .filter(img => img.status === 'pending' && img.prompt) .slice(0, maxInArticleImages) .sort((a, b) => (a.position || 0) - (b.position || 0)); pendingInArticle.forEach((img, idx) => { queue.push({ imageId: img.id || null, index: queueIndex++, label: `In-Article Image ${img.position || idx + 1}`, type: 'in_article', position: img.position || idx + 1, contentTitle: contentImages.content_title || `Content #${contentId}`, prompt: img.prompt || undefined, status: 'pending', progress: 0, imageUrl: null, error: null, }); }); return queue; }, [images]); // Generate images handler - Stage 1: Open modal immediately const handleGenerateImages = useCallback(async (contentId: number) => { try { // Get content images const contentImages = images.find(g => g.content_id === contentId); if (!contentImages) { toast.error('Content not found'); return; } // Fetch image generation settings to get max_in_article_images, model, and provider let maxInArticleImages = 2; // Default let model = null; let provider = null; try { const settings = await fetchImageGenerationSettings(); if (settings.success && settings.config) { maxInArticleImages = settings.config.max_in_article_images || 2; model = settings.config.model || null; provider = settings.config.provider || null; } } catch (error) { console.warn('Failed to fetch image settings, using default:', error); } // Store model and provider for modal display setImageModel(model); setImageProvider(provider); // Build image queue const queue = buildImageQueue(contentId, maxInArticleImages); if (queue.length === 0) { toast.info('No pending images with prompts found for this content'); return; } // STAGE 1: Open modal immediately with all progress bars setImageQueue(queue); setCurrentContentId(contentId); setIsQueueModalOpen(true); // Collect image IDs for API call const imageIds: number[] = queue .map(item => item.imageId) .filter((id): id is number => id !== null); console.log('[Generate Images] Stage 1 Complete: Modal opened with', queue.length, 'images'); console.log('[Generate Images] Image IDs to generate:', imageIds); console.log('[Generate Images] Max in-article images from settings:', maxInArticleImages); // STAGE 2: Start actual generation addAiLog({ timestamp: new Date().toISOString(), type: 'request', action: 'generate_images', data: { imageIds, contentId, totalImages: imageIds.length } }); const result = await generateImages(imageIds, contentId); if (result.success && result.task_id) { // Task started successfully - polling will be handled by ImageQueueModal setTaskId(result.task_id); console.log('[Generate Images] Stage 2: Task started with ID:', result.task_id); addAiLog({ timestamp: new Date().toISOString(), type: 'step', action: 'generate_images', stepName: 'Task Queued', data: { task_id: result.task_id, message: 'Image generation task queued' } }); } else { toast.error(result.error || 'Failed to start image generation'); setIsQueueModalOpen(false); setTaskId(null); addAiLog({ timestamp: new Date().toISOString(), type: 'error', action: 'generate_images', data: { error: result.error || 'Failed to start image generation' } }); } } catch (error: any) { console.error('[Generate Images] Exception:', error); toast.error(`Failed to initialize image generation: ${error.message}`); } }, [toast, images, buildImageQueue]); // Helper function to convert image_path to web-accessible URL const getImageUrl = useCallback((image: ContentImage | null): string | null => { if (!image || !image.image_path) return null; // Check if image_path is a valid local file path (not a URL) const isValidLocalPath = (imagePath: string): boolean => { if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) { return false; } return imagePath.includes('ai-images'); }; if (!isValidLocalPath(image.image_path)) return null; // Convert local file path to web-accessible URL if (image.image_path.includes('ai-images')) { const filename = image.image_path.split('ai-images/')[1] || image.image_path.split('ai-images\\')[1]; if (filename) { return `/images/ai-images/${filename}`; } } if (image.image_path.startsWith('/images/')) { return image.image_path; } const filename = image.image_path.split('/').pop() || image.image_path.split('\\').pop(); return filename ? `/images/ai-images/${filename}` : null; }, []); // Handle image click - open modal with single image const handleImageClick = useCallback((contentId: number, imageType: 'featured' | 'in_article', position?: number) => { const contentGroup = images.find(g => g.content_id === contentId); if (!contentGroup) return; let image: ContentImage | null = null; if (imageType === 'featured' && contentGroup.featured_image) { image = contentGroup.featured_image; } else if (imageType === 'in_article' && position) { image = contentGroup.in_article_images.find(img => img.position === position) || null; } if (image && image.status === 'generated') { const url = getImageUrl(image); if (url) { setModalImageUrl(url); setIsImageModalOpen(true); } } }, [images, getImageUrl]); // Get max in-article images from the data (to determine column count) const maxInArticleImages = useMemo(() => { if (images.length === 0) return 5; // Default const max = Math.max(...images.map(group => group.in_article_images.length)); return Math.max(max, 5); // At least 5 columns }, [images]); // Create page config const pageConfig = useMemo(() => { return createImagesPageConfig({ searchTerm, setSearchTerm, statusFilter, setStatusFilter, setCurrentPage, maxInArticleImages, onGenerateImages: handleGenerateImages, onImageClick: handleImageClick, }); }, [searchTerm, statusFilter, maxInArticleImages, handleGenerateImages, handleImageClick]); // Calculate header metrics const headerMetrics = useMemo(() => { if (!pageConfig?.headerMetrics) return []; return pageConfig.headerMetrics.map((metric) => ({ label: metric.label, value: metric.calculate({ images, totalCount }), accentColor: metric.accentColor, })); }, [pageConfig?.headerMetrics, images, totalCount]); // Writer navigation tabs const writerTabs = [ { label: 'Tasks', path: '/writer/tasks', icon: }, { label: 'Content', path: '/writer/content', icon: }, { label: 'Images', path: '/writer/images', icon: }, { label: 'Published', path: '/writer/published', icon: }, ]; return ( <> , color: 'orange' }} navigation={} /> { const stringValue = value === null || value === undefined ? '' : String(value); if (key === 'search') { setSearchTerm(stringValue); } else if (key === 'status') { setStatusFilter(stringValue); } setCurrentPage(1); }} onBulkExport={handleBulkExport} onBulkAction={handleBulkAction} getItemDisplayName={(row: ContentImagesGroup) => row.content_title || `Content #${row.content_id}`} onExport={async () => { toast.info('Export functionality coming soon'); }} onExportIcon={} selectionLabel="content" pagination={{ currentPage, totalPages, totalCount, onPageChange: setCurrentPage, }} selection={{ selectedIds, onSelectionChange: setSelectedIds, }} sorting={{ sortBy, sortDirection, onSort: handleSort, }} headerMetrics={headerMetrics} onFilterReset={() => { setSearchTerm(''); setStatusFilter(''); setCurrentPage(1); }} onRowAction={handleRowAction} /> { setIsQueueModalOpen(false); setImageQueue([]); setCurrentContentId(null); setTaskId(null); setImageModel(null); setImageProvider(null); // Reload images after closing if generation completed loadImages(); }} queue={imageQueue} totalImages={imageQueue.length} taskId={taskId} model={imageModel || undefined} provider={imageProvider || undefined} onUpdateQueue={setImageQueue} onLog={addAiLog} /> {/* Status Update Modal */} { setIsStatusModalOpen(false); setStatusUpdateContentId(null); setStatusUpdateRecordName(''); }} onConfirm={handleStatusUpdate} title="Update Image Status" recordName={statusUpdateRecordName} statusOptions={[ { value: 'pending', label: 'Pending' }, { value: 'generated', label: 'Generated' }, { value: 'failed', label: 'Failed' }, ]} isLoading={isUpdatingStatus} /> {/* Image Modal - 800px wide, auto height */} { setIsImageModalOpen(false); setModalImageUrl(null); }} className="max-w-[800px] w-full mx-4" > {modalImageUrl && (
Content image
)}
{/* AI Function Logs - Display below table (only when Resource Debug is enabled) */} {resourceDebugEnabled && aiLogs.length > 0 && (

AI Function Logs

{aiLogs.slice().reverse().map((log, index) => (
[{log.type.toUpperCase()}] {log.action} {log.stepName && ( {log.stepName} )} {log.percentage !== undefined && ( {log.percentage}% )}
{new Date(log.timestamp).toLocaleTimeString()}
                  {JSON.stringify(log.data, null, 2)}
                
))}
)} ); }