676 lines
24 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|