- Introduced a new endpoint in the backend to handle bulk updates of image statuses by content ID or image IDs. - Updated the frontend to include a new row action for updating image status and integrated a modal for status confirmation. - Enhanced the API service to support bulk status updates and updated the images page to manage status updates effectively.
548 lines
19 KiB
TypeScript
548 lines
19 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 TablePageTemplate from '../../templates/TablePageTemplate';
|
|
import {
|
|
fetchContentImages,
|
|
ContentImagesGroup,
|
|
ContentImagesResponse,
|
|
fetchImageGenerationSettings,
|
|
generateImages,
|
|
bulkUpdateImagesStatus,
|
|
} from '../../services/api';
|
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
|
import { FileIcon, DownloadIcon, BoltIcon } 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';
|
|
|
|
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<Array<{
|
|
timestamp: string;
|
|
type: 'request' | 'success' | 'error' | 'step';
|
|
action: string;
|
|
data: any;
|
|
stepName?: string;
|
|
percentage?: number;
|
|
}>>([]);
|
|
|
|
// 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<ContentImagesGroup[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// Filter state
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState('');
|
|
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);
|
|
|
|
// 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);
|
|
|
|
// 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]);
|
|
|
|
// 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[]) => {
|
|
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}`,
|
|
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}`,
|
|
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]);
|
|
|
|
// 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,
|
|
});
|
|
}, [searchTerm, statusFilter, maxInArticleImages, handleGenerateImages]);
|
|
|
|
// 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]);
|
|
|
|
return (
|
|
<>
|
|
<TablePageTemplate
|
|
title="Content Images"
|
|
titleIcon={<FileIcon className="text-purple-500 size-5" />}
|
|
subtitle="Manage images for content articles"
|
|
columns={pageConfig.columns}
|
|
data={images}
|
|
loading={loading}
|
|
showContent={showContent}
|
|
filters={pageConfig.filters}
|
|
filterValues={{
|
|
search: searchTerm,
|
|
status: statusFilter,
|
|
}}
|
|
onFilterChange={(key, value) => {
|
|
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={<DownloadIcon />}
|
|
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}
|
|
/>
|
|
<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}
|
|
onLog={addAiLog}
|
|
/>
|
|
|
|
{/* 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}
|
|
/>
|
|
|
|
{/* AI Function Logs - Display below table (only when Resource Debug is enabled) */}
|
|
{resourceDebugEnabled && aiLogs.length > 0 && (
|
|
<div className="mt-6 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
|
AI Function Logs
|
|
</h3>
|
|
<button
|
|
onClick={() => setAiLogs([])}
|
|
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
|
>
|
|
Clear Logs
|
|
</button>
|
|
</div>
|
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
|
{aiLogs.slice().reverse().map((log, index) => (
|
|
<div
|
|
key={index}
|
|
className={`p-3 rounded border text-xs font-mono ${
|
|
log.type === 'request'
|
|
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
|
|
: log.type === 'success'
|
|
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
|
|
: log.type === 'error'
|
|
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
|
|
: 'bg-purple-50 dark:bg-purple-900/20 border-purple-200 dark:border-purple-800'
|
|
}`}
|
|
>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className={`font-semibold ${
|
|
log.type === 'request'
|
|
? 'text-blue-700 dark:text-blue-300'
|
|
: log.type === 'success'
|
|
? 'text-green-700 dark:text-green-300'
|
|
: log.type === 'error'
|
|
? 'text-red-700 dark:text-red-300'
|
|
: 'text-purple-700 dark:text-purple-300'
|
|
}`}>
|
|
[{log.type.toUpperCase()}]
|
|
</span>
|
|
<span className="text-gray-700 dark:text-gray-300">
|
|
{log.action}
|
|
</span>
|
|
{log.stepName && (
|
|
<span className="text-xs px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
|
|
{log.stepName}
|
|
</span>
|
|
)}
|
|
{log.percentage !== undefined && (
|
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
|
{log.percentage}%
|
|
</span>
|
|
)}
|
|
</div>
|
|
<span className="text-gray-500 dark:text-gray-400">
|
|
{new Date(log.timestamp).toLocaleTimeString()}
|
|
</span>
|
|
</div>
|
|
<pre className="text-xs text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-words">
|
|
{JSON.stringify(log.data, null, 2)}
|
|
</pre>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|