Files
igny8/frontend/src/pages/Writer/Images.tsx
alorig e360c5fede Revert "12"
This reverts commit 636b7ddca9.
2025-11-28 13:33:27 +05:00

720 lines
25 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,
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<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);
// Image modal state
const [isImageModalOpen, setIsImageModalOpen] = useState(false);
const [modalImageUrl, setModalImageUrl] = useState<string | null>(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: <TaskIcon /> },
{ label: 'Content', path: '/writer/content', icon: <FileIcon /> },
{ label: 'Images', path: '/writer/images', icon: <ImageIcon /> },
{ label: 'Published', path: '/writer/published', icon: <CheckCircleIcon /> },
];
return (
<>
<PageHeader
title="Content Images"
badge={{ icon: <FileIcon />, color: 'orange' }}
navigation={<ModuleNavigationTabs tabs={writerTabs} />}
/>
<TablePageTemplate
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}
/>
{/* 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"
style={{ maxHeight: '90vh' }}
/>
</div>
)}
</Modal>
{/* 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>
)}
</>
);
}