/** * Images Page - Built with TablePageTemplate * Shows content images grouped by content - one row per content */ import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { Link } from 'react-router-dom'; import TablePageTemplate from '../../templates/TablePageTemplate'; import { fetchContentImages, fetchImages, fetchContent, fetchWriterContentFilterOptions, ContentImagesGroup, ContentImagesResponse, fetchImageGenerationSettings, generateImages, bulkUpdateImagesStatus, ContentImage, fetchAPI, deleteContent, bulkDeleteContent, } from '../../services/api'; import { useToast } from '../../components/ui/toast/ToastContainer'; import { FileIcon, DownloadIcon, ArrowRightIcon } from '../../icons'; import { PhotoIcon } from '@heroicons/react/24/outline'; import { createImagesPageConfig } from '../../config/pages/images.config'; import ImageQueueModal, { ImageQueueItem } from '../../components/common/ImageQueueModal'; import SingleRecordStatusUpdateModal from '../../components/common/SingleRecordStatusUpdateModal'; import PageHeader from '../../components/common/PageHeader'; import { Modal } from '../../components/ui/modal'; import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter'; import { useSiteStore } from '../../store/siteStore'; export default function Images() { const toast = useToast(); const { activeSite } = useSiteStore(); // Data state const [images, setImages] = useState([]); const [loading, setLoading] = useState(true); // Total counts for footer widget and header metrics (not page-filtered) const [totalContent, setTotalContent] = useState(0); const [totalDraft, setTotalDraft] = useState(0); const [totalReview, setTotalReview] = useState(0); const [totalApproved, setTotalApproved] = useState(0); const [totalPublished, setTotalPublished] = useState(0); const [totalImagesCount, setTotalImagesCount] = useState(0); const [generatedImagesCount, setGeneratedImagesCount] = useState(0); // Footer widget specific counts (image-based) const [totalComplete, setTotalComplete] = useState(0); const [totalPartial, setTotalPartial] = useState(0); const [totalPending, setTotalPending] = useState(0); // Filter state const [searchTerm, setSearchTerm] = useState(''); const [statusFilter, setStatusFilter] = useState(''); const [contentStatusFilter, setContentStatusFilter] = useState(''); const [contentStatusOptions, setContentStatusOptions] = useState | undefined>(undefined); 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); // Load dynamic filter options for content status const loadFilterOptions = useCallback(async (currentFilters?: { status?: string; search?: string; }) => { if (!activeSite) return; try { const options = await fetchWriterContentFilterOptions(activeSite.id, currentFilters); setContentStatusOptions(options.statuses || []); } catch (error) { console.error('Error loading filter options:', error); } }, [activeSite]); useEffect(() => { loadFilterOptions(); }, [activeSite]); useEffect(() => { loadFilterOptions({ status: contentStatusFilter || undefined, search: searchTerm || undefined, }); }, [contentStatusFilter, searchTerm, loadFilterOptions]); // 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 total metrics for footer widget and header metrics (not affected by pagination) const loadTotalMetrics = useCallback(async () => { try { // Fetch counts in parallel for performance const [allRes, draftRes, reviewRes, approvedRes, publishedRes, imagesRes, generatedImagesRes] = await Promise.all([ fetchContent({ page_size: 1, site_id: activeSite?.id }), fetchContent({ page_size: 1, status: 'draft', site_id: activeSite?.id }), fetchContent({ page_size: 1, status: 'review', site_id: activeSite?.id }), fetchContent({ page_size: 1, status: 'approved', site_id: activeSite?.id }), fetchContent({ page_size: 1, status: 'published', site_id: activeSite?.id }), fetchImages({ page_size: 1, site_id: activeSite?.id }), fetchImages({ page_size: 1, site_id: activeSite?.id, status: 'generated' }), ]); setTotalContent(allRes.count || 0); setTotalDraft(draftRes.count || 0); setTotalReview(reviewRes.count || 0); setTotalApproved(approvedRes.count || 0); setTotalPublished(publishedRes.count || 0); setTotalImagesCount(imagesRes.count || 0); setGeneratedImagesCount(generatedImagesRes.count || 0); } catch (error) { console.error('Error loading total metrics:', error); } }, [activeSite]); // Load total metrics on mount useEffect(() => { loadTotalMetrics(); }, [loadTotalMetrics]); // 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 content status filter if (contentStatusFilter) { filteredResults = filteredResults.filter(group => group.content_status === contentStatusFilter ); } // Client-side image 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); // Transform data to add 'id' field for TablePageTemplate selection const transformedResults = paginatedResults.map(group => ({ ...group, id: group.content_id // Add id field that mirrors content_id })); setImages(transformedResults); 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, contentStatusFilter, 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 - reset to page 1 when search term changes // Only depend on searchTerm to avoid pagination reset on page navigation useEffect(() => { const timer = setTimeout(() => { // Always reset to page 1 when search changes // The main useEffect will handle reloading when currentPage changes setCurrentPage(1); }, 500); return () => clearTimeout(timer); }, [searchTerm]); // 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]); // Delete handler for single content const handleDelete = useCallback(async (id: number) => { try { await deleteContent(id); toast.success('Content and images deleted successfully'); loadImages(); } catch (error: any) { toast.error(`Failed to delete: ${error.message}`); throw error; } }, [loadImages, toast]); // Bulk delete handler const handleBulkDelete = useCallback(async (ids: number[]) => { try { const result = await bulkDeleteContent(ids); toast.success(`Deleted ${result.deleted_count} content item(s) and their images`); loadImages(); return result; } catch (error: any) { toast.error(`Failed to bulk delete: ${error.message}`); throw error; } }, [loadImages, toast]); // Bulk action handler const handleBulkAction = useCallback(async (action: string, ids: string[]) => { toast.info(`Bulk action "${action}" for ${ids.length} items`); }, [toast]); // 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); } }, []); // 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 - process ALL pending images (no slice limit) const pendingInArticle = contentImages.in_article_images .filter(img => img.status === 'pending' && img.prompt) .sort((a, b) => (a.position || 0) - (b.position || 0)); pendingInArticle.forEach((img, idx) => { // Position is 0-indexed in backend, but labels should be 1-indexed for users const displayPosition = (img.position ?? idx) + 1; queue.push({ imageId: img.id || null, index: queueIndex++, label: `In-Article Image ${displayPosition}`, type: 'in_article', position: img.position ?? idx, 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 = 4; // Default fallback let model = null; let provider = null; try { const settings = await fetchImageGenerationSettings(); if (settings.success && settings.config) { maxInArticleImages = settings.config.max_in_article_images || 4; 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 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); } else { toast.error(result.error || 'Failed to start image generation'); setIsQueueModalOpen(false); setTaskId(null); } } 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 !== undefined) { // Position is 0-indexed, so check for undefined instead of falsy 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, contentStatusFilter, setContentStatusFilter, contentStatusOptions, setCurrentPage, maxInArticleImages, onGenerateImages: handleGenerateImages, onImageClick: handleImageClick, }); }, [searchTerm, statusFilter, contentStatusFilter, contentStatusOptions, maxInArticleImages, handleGenerateImages, handleImageClick]); // Calculate header metrics - use totals from API calls (not page data) // This ensures metrics show correct totals across all pages, not just current page const headerMetrics = useMemo(() => { if (!pageConfig?.headerMetrics) return []; // Override the calculate function to use pre-loaded totals instead of filtering page data // Also add a "Total Images" metric at the end const baseMetrics = pageConfig.headerMetrics.map((metric) => { let value: number; switch (metric.label) { case 'Content': value = totalContent || 0; break; case 'Draft': value = totalDraft; break; case 'In Review': value = totalReview; break; case 'Approved': value = totalApproved; break; case 'Published': value = totalPublished; break; case 'Total Images': value = totalImagesCount; return { label: metric.label, displayValue: `${generatedImagesCount}/${totalImagesCount}`, value, accentColor: metric.accentColor, tooltip: (metric as any).tooltip, }; default: value = metric.calculate({ images, totalCount }); } return { label: metric.label, value, accentColor: metric.accentColor, tooltip: (metric as any).tooltip, }; }); return baseMetrics; }, [pageConfig?.headerMetrics, images, totalCount, totalContent, totalDraft, totalReview, totalApproved, totalPublished, totalImagesCount, generatedImagesCount]); return ( <> , color: 'pink' }} parent="Writer" /> 0 ? { label: 'Generate Images', message: `${selectedIds.length} selected`, onClick: () => handleBulkAction('generate_images', selectedIds), } : images.filter(i => i.overall_status === 'ready').length > 0 ? { label: 'Review Content', href: '/writer/review', message: `${images.filter(i => i.overall_status === 'ready').length} ready`, } : undefined} onFilterChange={(key, value) => { const stringValue = value === null || value === undefined ? '' : String(value); if (key === 'search') { setSearchTerm(stringValue); } else if (key === 'content_status') { setContentStatusFilter(stringValue); } else if (key === 'status') { setStatusFilter(stringValue); } setCurrentPage(1); }} onBulkExport={handleBulkExport} onBulkAction={handleBulkAction} onDelete={handleDelete} onBulkDelete={handleBulkDelete} 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(''); setContentStatusFilter(''); 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} /> {/* 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
)}
{/* Three Widget Footer - Section 3 Layout with Standardized Workflow Widget */} 0 ? Math.round((totalComplete / totalCount) * 100) : 0}%` }, { label: 'Partial', value: totalPartial }, { label: 'Pending', value: totalPending }, ], progress: { value: totalCount > 0 ? Math.round((totalComplete / totalCount) * 100) : 0, label: 'Complete', color: 'purple', }, hint: totalPending > 0 ? `${totalPending} content item${totalPending !== 1 ? 's' : ''} need image generation` : 'All images generated!', statusInsight: totalPending > 0 ? `Select content items and generate images for articles.` : totalComplete > 0 ? `Images ready. Submit content to Review for publishing.` : `No content with image prompts. Generate content first.`, }} module="writer" showCredits={true} analyticsHref="/account/usage" /> ); }