745 lines
26 KiB
TypeScript
745 lines
26 KiB
TypeScript
/**
|
|
* 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<ContentImagesGroup[]>([]);
|
|
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<Array<{value: string; label: string}> | undefined>(undefined);
|
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
|
|
// 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<string>('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<ImageQueueItem[]>([]);
|
|
const [currentContentId, setCurrentContentId] = useState<number | null>(null);
|
|
const [taskId, setTaskId] = useState<string | null>(null);
|
|
const [imageModel, setImageModel] = useState<string | null>(null);
|
|
const [imageProvider, setImageProvider] = useState<string | null>(null);
|
|
|
|
// Status update modal state
|
|
const [isStatusModalOpen, setIsStatusModalOpen] = useState(false);
|
|
const [statusUpdateContentId, setStatusUpdateContentId] = useState<number | null>(null);
|
|
const [statusUpdateRecordName, setStatusUpdateRecordName] = useState<string>('');
|
|
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
|
|
|
// Image modal state
|
|
const [isImageModalOpen, setIsImageModalOpen] = useState(false);
|
|
const [modalImageUrl, setModalImageUrl] = useState<string | null>(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 (
|
|
<>
|
|
<PageHeader
|
|
title="Content Images"
|
|
badge={{ icon: <PhotoIcon />, color: 'pink' }}
|
|
parent="Writer"
|
|
/>
|
|
<TablePageTemplate
|
|
columns={pageConfig.columns}
|
|
data={images}
|
|
loading={loading}
|
|
showContent={showContent}
|
|
filters={pageConfig.filters}
|
|
filterValues={{
|
|
search: searchTerm,
|
|
content_status: contentStatusFilter,
|
|
status: statusFilter,
|
|
}}
|
|
nextAction={selectedIds.length > 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={<DownloadIcon />}
|
|
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}
|
|
/>
|
|
<ImageQueueModal
|
|
isOpen={isQueueModalOpen}
|
|
onClose={() => {
|
|
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 */}
|
|
<SingleRecordStatusUpdateModal
|
|
isOpen={isStatusModalOpen}
|
|
onClose={() => {
|
|
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 */}
|
|
<Modal
|
|
isOpen={isImageModalOpen}
|
|
onClose={() => {
|
|
setIsImageModalOpen(false);
|
|
setModalImageUrl(null);
|
|
}}
|
|
className="max-w-[800px] w-full mx-4"
|
|
>
|
|
{modalImageUrl && (
|
|
<div className="p-6">
|
|
<img
|
|
src={modalImageUrl}
|
|
alt="Content image"
|
|
className="w-full h-auto object-contain rounded-lg max-h-[90vh]"
|
|
/>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
|
|
{/* Three Widget Footer - Section 3 Layout with Standardized Workflow Widget */}
|
|
<StandardThreeWidgetFooter
|
|
submoduleColor="purple"
|
|
pageProgress={{
|
|
title: 'Page Progress',
|
|
submoduleColor: 'purple',
|
|
metrics: [
|
|
{ label: 'Content Items', value: totalCount },
|
|
{ label: 'Complete', value: totalComplete, percentage: `${totalCount > 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"
|
|
/>
|
|
</>
|
|
);
|
|
}
|