Files
igny8/frontend/src/pages/Writer/Images.tsx
Desktop 5bd2b00ee4 x
2025-11-13 00:13:44 +05:00

676 lines
24 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,
} 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';
import PageHeader from '../../components/common/PageHeader';
import Lightbox from 'yet-another-react-lightbox';
import 'yet-another-react-lightbox/styles.css';
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);
// Lightbox state
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const [lightboxSlides, setLightboxSlides] = useState<Array<{ src: string; alt?: string }>>([]);
// 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}`,
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,
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 lightbox with all images from the same content
const handleImageClick = useCallback((contentId: number, imageType: 'featured' | 'in_article', position?: number) => {
const contentGroup = images.find(g => g.content_id === contentId);
if (!contentGroup) return;
// Collect all generated images from this content
const slides: Array<{ src: string; alt?: string }> = [];
let startIndex = 0;
// Add featured image first (if it exists and is generated)
if (contentGroup.featured_image && contentGroup.featured_image.status === 'generated') {
const url = getImageUrl(contentGroup.featured_image);
if (url) {
slides.push({
src: url,
alt: contentGroup.featured_image.prompt || 'Featured image',
});
// If clicked image is featured, start at index 0
if (imageType === 'featured') {
startIndex = 0;
}
}
}
// Add in-article images in order
const sortedInArticle = [...contentGroup.in_article_images]
.filter(img => img.status === 'generated')
.sort((a, b) => (a.position || 0) - (b.position || 0));
sortedInArticle.forEach((img) => {
const url = getImageUrl(img);
if (url) {
const currentIndex = slides.length;
slides.push({
src: url,
alt: img.prompt || `In-article image ${img.position}`,
});
// If clicked image is this in-article image, set start index
if (imageType === 'in_article' && img.position === position) {
startIndex = currentIndex;
}
}
});
if (slides.length > 0) {
setLightboxSlides(slides);
setLightboxIndex(startIndex);
setLightboxOpen(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]);
return (
<>
<PageHeader
title="Content Images"
badge={{ icon: <FileIcon />, color: 'orange' }}
/>
<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}
/>
{/* Lightbox */}
{lightboxOpen && (
<div className="fixed inset-0 z-[99999]">
<Lightbox
open={lightboxOpen}
close={() => setLightboxOpen(false)}
index={lightboxIndex}
slides={lightboxSlides}
on={{ view: ({ index }) => setLightboxIndex(index) }}
/>
{/* Custom Close Button - Match Modal Style */}
<button
onClick={() => setLightboxOpen(false)}
className="absolute right-3 top-3 z-[999999] flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
aria-label="Close lightbox"
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
fill="currentColor"
/>
</svg>
</button>
</div>
)}
{/* 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>
)}
</>
);
}