diff --git a/backend/igny8_core/ai/functions/generate_image_prompts.py b/backend/igny8_core/ai/functions/generate_image_prompts.py index 586c7a24..b77cebe1 100644 --- a/backend/igny8_core/ai/functions/generate_image_prompts.py +++ b/backend/igny8_core/ai/functions/generate_image_prompts.py @@ -151,9 +151,9 @@ class GenerateImagePromptsFunction(BaseAIFunction): prompts_created = 0 with transaction.atomic(): - # Save featured image prompt + # Save featured image prompt - use content instead of task Images.objects.update_or_create( - task=content.task, + content=content, image_type='featured', defaults={ 'prompt': parsed['featured_prompt'], @@ -171,7 +171,7 @@ class GenerateImagePromptsFunction(BaseAIFunction): heading = h2_headings[idx] if idx < len(h2_headings) else f"Section {idx + 1}" Images.objects.update_or_create( - task=content.task, + content=content, image_type='in_article', position=idx + 1, defaults={ diff --git a/backend/igny8_core/modules/writer/migrations/0007_add_content_to_images.py b/backend/igny8_core/modules/writer/migrations/0007_add_content_to_images.py new file mode 100644 index 00000000..837e6a06 --- /dev/null +++ b/backend/igny8_core/modules/writer/migrations/0007_add_content_to_images.py @@ -0,0 +1,83 @@ +# Generated manually for adding content ForeignKey to Images model + +from django.db import migrations, models +import django.db.models.deletion + + +def migrate_task_to_content(apps, schema_editor): + """Migrate existing Images to use content instead of task""" + Images = apps.get_model('writer', 'Images') + Content = apps.get_model('writer', 'Content') + + # Update images that have a task with a content_record + for image in Images.objects.filter(task__isnull=False, content__isnull=True): + try: + # Try to get content via task.content_record + task = image.task + if task: + try: + content = Content.objects.get(task=task) + image.content = content + image.save(update_fields=['content']) + except Content.DoesNotExist: + # If content doesn't exist, leave content as null + pass + except Exception: + # If any error occurs, leave content as null + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('writer', '0006_update_status_choices'), + ] + + operations = [ + # Make task field nullable first + migrations.AlterField( + model_name='images', + name='task', + field=models.ForeignKey( + blank=True, + help_text='The task this image belongs to (legacy, use content instead)', + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='images', + to='writer.tasks' + ), + ), + # Add content field + migrations.AddField( + model_name='images', + name='content', + field=models.ForeignKey( + blank=True, + help_text='The content this image belongs to (preferred)', + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='images', + to='writer.content' + ), + ), + # Update ordering + migrations.AlterModelOptions( + name='images', + options={'ordering': ['content', 'position', '-created_at'], 'verbose_name': 'Image', 'verbose_name_plural': 'Images'}, + ), + # Add new indexes + migrations.AddIndex( + model_name='images', + index=models.Index(fields=['content', 'image_type'], name='igny8_image_content_image_type_idx'), + ), + migrations.AddIndex( + model_name='images', + index=models.Index(fields=['content', 'position'], name='igny8_image_content_position_idx'), + ), + # Data migration: populate content field from task.content_record + migrations.RunPython( + code=migrate_task_to_content, + reverse_code=migrations.RunPython.noop, + ), + ] + diff --git a/backend/igny8_core/modules/writer/models.py b/backend/igny8_core/modules/writer/models.py index b1e8bca8..c8c525cd 100644 --- a/backend/igny8_core/modules/writer/models.py +++ b/backend/igny8_core/modules/writer/models.py @@ -138,7 +138,7 @@ class Content(SiteSectorBaseModel): class Images(SiteSectorBaseModel): - """Images model for task-related images (featured, desktop, mobile)""" + """Images model for content-related images (featured, desktop, mobile, in-article)""" IMAGE_TYPE_CHOICES = [ ('featured', 'Featured Image'), @@ -147,11 +147,21 @@ class Images(SiteSectorBaseModel): ('in_article', 'In-Article Image'), ] + content = models.ForeignKey( + Content, + on_delete=models.CASCADE, + related_name='images', + null=True, + blank=True, + help_text="The content this image belongs to (preferred)" + ) task = models.ForeignKey( Tasks, on_delete=models.CASCADE, related_name='images', - help_text="The task this image belongs to" + null=True, + blank=True, + help_text="The task this image belongs to (legacy, use content instead)" ) image_type = models.CharField(max_length=50, choices=IMAGE_TYPE_CHOICES, default='featured') image_url = models.URLField(blank=True, null=True, help_text="URL of the generated/stored image") @@ -164,23 +174,33 @@ class Images(SiteSectorBaseModel): class Meta: db_table = 'igny8_images' - ordering = ['task', 'position', '-created_at'] + ordering = ['content', 'position', '-created_at'] verbose_name = 'Image' verbose_name_plural = 'Images' indexes = [ + models.Index(fields=['content', 'image_type']), models.Index(fields=['task', 'image_type']), models.Index(fields=['status']), + models.Index(fields=['content', 'position']), models.Index(fields=['task', 'position']), ] def save(self, *args, **kwargs): - """Automatically set account, site, and sector from task""" - if self.task: + """Automatically set account, site, and sector from content or task""" + # Prefer content over task + if self.content: + self.account = self.content.account + self.site = self.content.site + self.sector = self.content.sector + elif self.task: self.account = self.task.account self.site = self.task.site self.sector = self.task.sector super().save(*args, **kwargs) def __str__(self): - return f"{self.task.title} - {self.image_type}" + content_title = self.content.title if self.content else None + task_title = self.task.title if self.task else None + title = content_title or task_title or 'Unknown' + return f"{title} - {self.image_type}" diff --git a/backend/igny8_core/modules/writer/serializers.py b/backend/igny8_core/modules/writer/serializers.py index a1930af7..ba47e63f 100644 --- a/backend/igny8_core/modules/writer/serializers.py +++ b/backend/igny8_core/modules/writer/serializers.py @@ -111,6 +111,7 @@ class TasksSerializer(serializers.ModelSerializer): class ImagesSerializer(serializers.ModelSerializer): """Serializer for Images model""" task_title = serializers.SerializerMethodField() + content_title = serializers.SerializerMethodField() class Meta: model = Images @@ -118,6 +119,8 @@ class ImagesSerializer(serializers.ModelSerializer): 'id', 'task_id', 'task_title', + 'content_id', + 'content_title', 'image_type', 'image_url', 'image_path', @@ -139,6 +142,38 @@ class ImagesSerializer(serializers.ModelSerializer): except Tasks.DoesNotExist: return None return None + + def get_content_title(self, obj): + """Get content title""" + if obj.content: + return obj.content.title or obj.content.meta_title + return None + + +class ContentImageSerializer(serializers.ModelSerializer): + """Serializer for individual image in grouped content images""" + class Meta: + model = Images + fields = [ + 'id', + 'image_type', + 'image_url', + 'image_path', + 'prompt', + 'status', + 'position', + 'created_at', + 'updated_at', + ] + + +class ContentImagesGroupSerializer(serializers.Serializer): + """Serializer for grouped content images - one row per content""" + content_id = serializers.IntegerField() + content_title = serializers.CharField() + featured_image = ContentImageSerializer(allow_null=True) + in_article_images = ContentImageSerializer(many=True) + overall_status = serializers.CharField() # 'pending', 'partial', 'complete', 'failed' class ContentSerializer(serializers.ModelSerializer): diff --git a/backend/igny8_core/modules/writer/views.py b/backend/igny8_core/modules/writer/views.py index c253fbe3..f567e4f5 100644 --- a/backend/igny8_core/modules/writer/views.py +++ b/backend/igny8_core/modules/writer/views.py @@ -2,7 +2,8 @@ from rest_framework import viewsets, filters, status from rest_framework.decorators import action from rest_framework.response import Response from django_filters.rest_framework import DjangoFilterBackend -from django.db import transaction +from django.db import transaction, models +from django.db.models import Q from igny8_core.api.base import SiteSectorModelViewSet from igny8_core.api.pagination import CustomPageNumberPagination from .models import Tasks, Images, Content @@ -348,15 +349,15 @@ class TasksViewSet(SiteSectorModelViewSet): class ImagesViewSet(SiteSectorModelViewSet): """ - ViewSet for managing task images + ViewSet for managing content images """ queryset = Images.objects.all() serializer_class = ImagesSerializer filter_backends = [DjangoFilterBackend, filters.OrderingFilter] ordering_fields = ['created_at', 'position'] - ordering = ['task', 'position', '-created_at'] - filterset_fields = ['task_id', 'image_type', 'status'] + ordering = ['content', 'position', '-created_at'] + filterset_fields = ['task_id', 'content_id', 'image_type', 'status'] def perform_create(self, serializer): """Override to automatically set account""" @@ -432,6 +433,86 @@ class ImagesViewSet(SiteSectorModelViewSet): 'error': f'Failed to start image generation: {str(e)}', 'type': 'TaskError' }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + @action(detail=False, methods=['get'], url_path='content_images', url_name='content_images') + def content_images(self, request): + """Get images grouped by content - one row per content with featured and in-article images""" + from .serializers import ContentImagesGroupSerializer, ContentImageSerializer + + account = getattr(request, 'account', None) + + # Get all content that has images (either directly or via task) + # First, get content with direct image links + queryset = Content.objects.filter(images__isnull=False) + if account: + queryset = queryset.filter(account=account) + + # Also get content from images linked via task + task_linked_images = Images.objects.filter(task__isnull=False, content__isnull=True) + if account: + task_linked_images = task_linked_images.filter(account=account) + + # Get content IDs from task-linked images + task_content_ids = set() + for image in task_linked_images: + if image.task and hasattr(image.task, 'content_record'): + try: + content = image.task.content_record + if content: + task_content_ids.add(content.id) + except Exception: + pass + + # Combine both sets of content IDs + content_ids = set(queryset.values_list('id', flat=True).distinct()) + content_ids.update(task_content_ids) + + # Build grouped response + grouped_data = [] + for content_id in content_ids: + try: + content = Content.objects.get(id=content_id) + # Get images linked directly to content OR via task + content_images = Images.objects.filter( + Q(content=content) | Q(task=content.task) + ).order_by('position') + + # Get featured image + featured_image = content_images.filter(image_type='featured').first() + + # Get in-article images (sorted by position) + in_article_images = list(content_images.filter(image_type='in_article').order_by('position')) + + # Determine overall status + all_images = list(content_images) + if not all_images: + overall_status = 'pending' + elif all(img.status == 'generated' for img in all_images): + overall_status = 'complete' + elif any(img.status == 'failed' for img in all_images): + overall_status = 'failed' + elif any(img.status == 'generated' for img in all_images): + overall_status = 'partial' + else: + overall_status = 'pending' + + grouped_data.append({ + 'content_id': content.id, + 'content_title': content.title or content.meta_title or f"Content #{content.id}", + 'featured_image': ContentImageSerializer(featured_image).data if featured_image else None, + 'in_article_images': [ContentImageSerializer(img).data for img in in_article_images], + 'overall_status': overall_status, + }) + except Content.DoesNotExist: + continue + + # Sort by content title + grouped_data.sort(key=lambda x: x['content_title']) + + return Response({ + 'count': len(grouped_data), + 'results': grouped_data + }, status=status.HTTP_200_OK) class ContentViewSet(SiteSectorModelViewSet): diff --git a/frontend/src/components/common/ContentImageCell.tsx b/frontend/src/components/common/ContentImageCell.tsx new file mode 100644 index 00000000..e2dfdb46 --- /dev/null +++ b/frontend/src/components/common/ContentImageCell.tsx @@ -0,0 +1,150 @@ +/** + * ContentImageCell Component + * Displays image prompt, placeholder, or actual image based on status + */ +import React, { useState } from 'react'; +import Badge from '../ui/badge/Badge'; + +export interface ContentImageData { + id?: number; + image_type?: string; + image_url?: string | null; + image_path?: string | null; + prompt?: string | null; + status: string; + position?: number; + created_at?: string; + updated_at?: string; +} + +interface ContentImageCellProps { + image: ContentImageData | null; + maxPromptLength?: number; +} + +export default function ContentImageCell({ image, maxPromptLength = 100 }: ContentImageCellProps) { + const [showFullPrompt, setShowFullPrompt] = useState(false); + + if (!image) { + return ( +
-
+ ); + } + + const prompt = image.prompt || ''; + const shouldTruncate = prompt.length > maxPromptLength; + const displayPrompt = showFullPrompt || !shouldTruncate ? prompt : `${prompt.substring(0, maxPromptLength)}...`; + + return ( +
+ {/* Prompt Text */} + {prompt && ( +
+

+ {displayPrompt} + {shouldTruncate && ( + + )} +

+
+ )} + + {/* Image Display */} +
+ {image.status === 'pending' && ( +
+
+ + + +

Pending

+
+
+ )} + + {image.status === 'generated' && image.image_url && ( + + {prompt { + // Fallback to placeholder if image fails to load + const target = e.target as HTMLImageElement; + target.style.display = 'none'; + target.parentElement!.innerHTML = ` +
+

Image not available

+
+ `; + }} + /> +
+ )} + + {image.status === 'generated' && !image.image_url && ( +
+

No URL available

+
+ )} + + {image.status === 'failed' && ( +
+
+ + + +

Failed

+
+
+ )} + + {/* Status Badge */} +
+ + {image.status} + +
+
+
+ ); +} + diff --git a/frontend/src/config/pages/images.config.tsx b/frontend/src/config/pages/images.config.tsx index 25c59005..8046c4e4 100644 --- a/frontend/src/config/pages/images.config.tsx +++ b/frontend/src/config/pages/images.config.tsx @@ -1,17 +1,13 @@ /** * Images Page Configuration - * Centralized config for Images page table, filters, and actions + * Centralized config for Content Images page table, filters, and actions + * Shows one row per content with featured and in-article images */ import React from 'react'; -import { - titleColumn, - statusColumn, - createdColumn, -} from '../snippets/columns.snippets'; import Badge from '../../components/ui/badge/Badge'; -import { formatRelativeDate } from '../../utils/date'; -import { TaskImage } from '../../services/api'; +import ContentImageCell, { ContentImageData } from '../../components/common/ContentImageCell'; +import { ContentImagesGroup } from '../../services/api'; export interface ColumnConfig { key: string; @@ -35,121 +31,112 @@ export interface HeaderMetricConfig { label: string; value: number; accentColor: 'blue' | 'green' | 'amber' | 'purple'; - calculate: (data: { images: any[]; totalCount: number }) => number; + calculate: (data: { images: ContentImagesGroup[]; totalCount: number }) => number; } export interface ImagesPageConfig { columns: ColumnConfig[]; filters: FilterConfig[]; headerMetrics: HeaderMetricConfig[]; + maxInArticleImages: number; // Maximum number of in-article image columns to show } export const createImagesPageConfig = ( handlers: { searchTerm: string; setSearchTerm: (value: string) => void; - imageTypeFilter: string; - setImageTypeFilter: (value: string) => void; statusFilter: string; setStatusFilter: (value: string) => void; setCurrentPage: (page: number) => void; + maxInArticleImages?: number; // Optional: max in-article images to display } ): ImagesPageConfig => { + const maxImages = handlers.maxInArticleImages || 5; // Default to 5 in-article images + + // Build columns dynamically based on max in-article images + const columns: ColumnConfig[] = [ + { + key: 'content_title', + label: 'Content Title', + sortable: false, + width: '250px', + render: (_value: string, row: ContentImagesGroup) => ( +
+ + {row.content_title} + +
+ ID: {row.content_id} +
+
+ ), + }, + { + key: 'featured_image', + label: 'Featured Image', + sortable: false, + width: '200px', + render: (_value: any, row: ContentImagesGroup) => ( + + ), + }, + ]; + + // Add in-article image columns dynamically + for (let i = 1; i <= maxImages; i++) { + columns.push({ + key: `in_article_${i}`, + label: `In-Article ${i}`, + sortable: false, + width: '200px', + render: (_value: any, row: ContentImagesGroup) => { + const image = row.in_article_images.find(img => img.position === i); + return ; + }, + }); + } + + // Add overall status column + columns.push({ + key: 'overall_status', + label: 'Status', + sortable: false, + width: '120px', + render: (value: string) => { + const statusColors: Record = { + 'complete': 'success', + 'partial': 'info', + 'pending': 'warning', + 'failed': 'error', + }; + const labels: Record = { + 'complete': 'Complete', + 'partial': 'Partial', + 'pending': 'Pending', + 'failed': 'Failed', + }; + return ( + + {labels[value] || value} + + ); + }, + }); + return { - columns: [ - { - key: 'task_title', - label: 'Task', - sortable: false, - width: '250px', - render: (_value: string, row: TaskImage) => ( - - {row.task_title || '-'} - - ), - }, - { - key: 'image_type', - label: 'Image Type', - sortable: false, - width: '150px', - render: (value: string) => ( - - {value?.replace('_', ' ') || '-'} - - ), - }, - { - key: 'image_url', - label: 'Image', - sortable: false, - width: '200px', - render: (value: string) => { - if (!value) return -; - return ( - - View Image - - ); - }, - }, - { - ...statusColumn, - sortable: true, - sortField: 'status', - render: (value: string) => { - const statusColors: Record = { - 'pending': 'warning', - 'generated': 'success', - 'failed': 'error', - }; - return ( - - {value} - - ); - }, - }, - { - key: 'position', - label: 'Position', - sortable: false, - width: '100px', - render: (value: number) => value || 0, - }, - { - ...createdColumn, - sortable: true, - sortField: 'created_at', - render: (value: string) => formatRelativeDate(value), - }, - ], + columns, filters: [ { key: 'search', label: 'Search', type: 'text', - placeholder: 'Search by task title...', - }, - { - key: 'image_type', - label: 'Image Type', - type: 'select', - options: [ - { value: '', label: 'All Types' }, - { value: 'featured', label: 'Featured Image' }, - { value: 'desktop', label: 'Desktop Image' }, - { value: 'mobile', label: 'Mobile Image' }, - { value: 'in_article', label: 'In-Article Image' }, - ], + placeholder: 'Search by content title...', }, { key: 'status', @@ -157,38 +144,39 @@ export const createImagesPageConfig = ( type: 'select', options: [ { value: '', label: 'All Status' }, + { value: 'complete', label: 'Complete' }, + { value: 'partial', label: 'Partial' }, { value: 'pending', label: 'Pending' }, - { value: 'generated', label: 'Generated' }, { value: 'failed', label: 'Failed' }, ], }, ], headerMetrics: [ { - label: 'Total Images', + label: 'Total Content', value: 0, accentColor: 'blue' as const, calculate: (data) => data.totalCount || 0, }, { - label: 'Generated', + label: 'Complete', value: 0, accentColor: 'green' as const, - calculate: (data) => data.images.filter((i: TaskImage) => i.status === 'generated').length, + calculate: (data) => data.images.filter((i: ContentImagesGroup) => i.overall_status === 'complete').length, + }, + { + label: 'Partial', + value: 0, + accentColor: 'info' as const, + calculate: (data) => data.images.filter((i: ContentImagesGroup) => i.overall_status === 'partial').length, }, { label: 'Pending', value: 0, accentColor: 'amber' as const, - calculate: (data) => data.images.filter((i: TaskImage) => i.status === 'pending').length, - }, - { - label: 'Failed', - value: 0, - accentColor: 'error' as const, - calculate: (data) => data.images.filter((i: TaskImage) => i.status === 'failed').length, + calculate: (data) => data.images.filter((i: ContentImagesGroup) => i.overall_status === 'pending').length, }, ], + maxInArticleImages: maxImages, }; }; - diff --git a/frontend/src/pages/Writer/Content.tsx b/frontend/src/pages/Writer/Content.tsx index 40c031e4..fc45b887 100644 --- a/frontend/src/pages/Writer/Content.tsx +++ b/frontend/src/pages/Writer/Content.tsx @@ -3,7 +3,7 @@ * Displays content from Content table with filters and pagination */ -import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import TablePageTemplate from '../../templates/TablePageTemplate'; import { fetchContent, @@ -16,6 +16,8 @@ import { FileIcon } from '../../icons'; import { createContentPageConfig } from '../../config/pages/content.config'; import { useSectorStore } from '../../store/sectorStore'; import { usePageSizeStore } from '../../store/pageSizeStore'; +import ProgressModal from '../../components/common/ProgressModal'; +import { useProgressModal } from '../../hooks/useProgressModal'; export default function Content() { const toast = useToast(); @@ -41,6 +43,10 @@ export default function Content() { const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); const [showContent, setShowContent] = useState(false); + // Progress modal for AI functions + const progressModal = useProgressModal(); + const hasReloadedRef = useRef(false); + // Load content - wrapped in useCallback const loadContent = useCallback(async () => { setLoading(true); @@ -152,9 +158,16 @@ export default function Content() { const result = await generateImagePrompts([row.id]); if (result.success) { if (result.task_id) { - toast.success('Image prompts generation started'); + // Open progress modal for async task + progressModal.openModal( + result.task_id, + 'Generate Image Prompts', + 'ai-generate-image-prompts-01-desktop' + ); } else { + // Synchronous completion toast.success(`Image prompts generated: ${result.prompts_created || 0} prompt${(result.prompts_created || 0) === 1 ? '' : 's'} created`); + loadContent(); // Reload to show new prompts } } else { toast.error(result.error || 'Failed to generate image prompts'); @@ -163,7 +176,7 @@ export default function Content() { toast.error(`Failed to generate prompts: ${error.message}`); } } - }, [toast]); + }, [toast, progressModal, loadContent]); return ( <> @@ -207,6 +220,30 @@ export default function Content() { onRowAction={handleRowAction} getItemDisplayName={(row: ContentType) => row.meta_title || row.title || `Content #${row.id}`} /> + + {/* Progress Modal for AI Functions */} + { + const wasCompleted = progressModal.progress.status === 'completed'; + progressModal.closeModal(); + // Reload data after modal closes (if completed) + if (wasCompleted && !hasReloadedRef.current) { + hasReloadedRef.current = true; + loadContent(); + setTimeout(() => { + hasReloadedRef.current = false; + }, 1000); + } + }} + /> ); } diff --git a/frontend/src/pages/Writer/Images.tsx b/frontend/src/pages/Writer/Images.tsx index 574dbdb5..0b43cc42 100644 --- a/frontend/src/pages/Writer/Images.tsx +++ b/frontend/src/pages/Writer/Images.tsx @@ -1,17 +1,14 @@ /** * Images Page - Built with TablePageTemplate - * Consistent with Keywords page layout, structure and design + * Shows content images grouped by content - one row per content */ import { useState, useEffect, useMemo, useCallback } from 'react'; import TablePageTemplate from '../../templates/TablePageTemplate'; import { - fetchTaskImages, - deleteTaskImage, - bulkDeleteTaskImages, - autoGenerateImages, - TaskImage, - TaskImageFilters, + fetchContentImages, + ContentImagesGroup, + ContentImagesResponse, } from '../../services/api'; import { useToast } from '../../components/ui/toast/ToastContainer'; import { FileIcon, DownloadIcon } from '../../icons'; @@ -21,23 +18,23 @@ export default function Images() { const toast = useToast(); // Data state - const [images, setImages] = useState([]); + const [images, setImages] = useState([]); const [loading, setLoading] = useState(true); // Filter state const [searchTerm, setSearchTerm] = useState(''); - const [imageTypeFilter, setImageTypeFilter] = useState(''); const [statusFilter, setStatusFilter] = useState(''); const [selectedIds, setSelectedIds] = useState([]); - // Pagination state + // 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('created_at'); - const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); + const [sortBy, setSortBy] = useState('content_title'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const [showContent, setShowContent] = useState(false); // Load images - wrapped in useCallback @@ -45,30 +42,46 @@ export default function Images() { setLoading(true); setShowContent(false); try { - const ordering = sortBy ? `${sortDirection === 'desc' ? '-' : ''}${sortBy}` : '-created_at'; - - const filters: TaskImageFilters = { - ...(imageTypeFilter && { image_type: imageTypeFilter }), - ...(statusFilter && { status: statusFilter }), - page: currentPage, - ordering, - }; - - // Note: TaskImages API doesn't support search by task title yet - // We'll filter client-side for now - const data = await fetchTaskImages(filters); + const data: ContentImagesResponse = await fetchContentImages(); let filteredResults = data.results || []; // Client-side search filter if (searchTerm) { - filteredResults = filteredResults.filter(img => - img.task_title?.toLowerCase().includes(searchTerm.toLowerCase()) + filteredResults = filteredResults.filter(group => + group.content_title?.toLowerCase().includes(searchTerm.toLowerCase()) ); } - setImages(filteredResults); + // 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 / 10)); + setTotalPages(Math.ceil(filteredResults.length / pageSize)); setTimeout(() => { setShowContent(true); @@ -80,7 +93,7 @@ export default function Images() { setShowContent(true); setLoading(false); } - }, [currentPage, imageTypeFilter, statusFilter, sortBy, sortDirection, searchTerm]); + }, [currentPage, statusFilter, sortBy, sortDirection, searchTerm, toast]); useEffect(() => { loadImages(); @@ -101,7 +114,7 @@ export default function Images() { // Handle sorting const handleSort = (field: string, direction: 'asc' | 'desc') => { - setSortBy(field || 'created_at'); + setSortBy(field || 'content_title'); setSortDirection(direction); setCurrentPage(1); }; @@ -116,37 +129,31 @@ export default function Images() { } catch (error: any) { throw error; } - }, []); + }, [toast]); // Bulk action handler const handleBulkAction = useCallback(async (action: string, ids: string[]) => { - if (action === 'generate_images') { - try { - const numIds = ids.map(id => parseInt(id)); - // Note: autoGenerateImages expects task_ids, not image_ids - // This would need to be adjusted based on API design - toast.info(`Generate images for ${ids.length} items`); - // await autoGenerateImages(numIds); - } catch (error: any) { - toast.error(`Failed to generate images: ${error.message}`); - } - } else { - toast.info(`Bulk action "${action}" for ${ids.length} items`); - } - }, []); + toast.info(`Bulk action "${action}" for ${ids.length} items`); + }, [toast]); + + // 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, - imageTypeFilter, - setImageTypeFilter, statusFilter, setStatusFilter, setCurrentPage, + maxInArticleImages, }); - }, [searchTerm, imageTypeFilter, statusFilter]); + }, [searchTerm, statusFilter, maxInArticleImages]); // Calculate header metrics const headerMetrics = useMemo(() => { @@ -160,9 +167,9 @@ export default function Images() { return ( } - subtitle="Manage images for content tasks" + subtitle="Manage images for content articles" columns={pageConfig.columns} data={images} loading={loading} @@ -170,40 +177,25 @@ export default function Images() { filters={pageConfig.filters} filterValues={{ search: searchTerm, - image_type: imageTypeFilter, status: statusFilter, }} onFilterChange={(key, value) => { const stringValue = value === null || value === undefined ? '' : String(value); if (key === 'search') { setSearchTerm(stringValue); - } else if (key === 'image_type') { - setImageTypeFilter(stringValue); } else if (key === 'status') { setStatusFilter(stringValue); } setCurrentPage(1); }} - onDelete={async (id: number) => { - await deleteTaskImage(id); - loadImages(); - }} - onBulkDelete={async (ids: number[]) => { - // Note: bulkDeleteTaskImages doesn't exist yet, using individual deletes - for (const id of ids) { - await deleteTaskImage(id); - } - loadImages(); - return { deleted_count: ids.length }; - }} onBulkExport={handleBulkExport} onBulkAction={handleBulkAction} - getItemDisplayName={(row: TaskImage) => row.task_title || `Image ${row.id}`} + getItemDisplayName={(row: ContentImagesGroup) => row.content_title || `Content #${row.content_id}`} onExport={async () => { toast.info('Export functionality coming soon'); }} onExportIcon={} - selectionLabel="image" + selectionLabel="content" pagination={{ currentPage, totalPages, @@ -222,7 +214,6 @@ export default function Images() { headerMetrics={headerMetrics} onFilterReset={() => { setSearchTerm(''); - setImageTypeFilter(''); setStatusFilter(''); setCurrentPage(1); }} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index b704b4dc..5551e0d3 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1002,6 +1002,36 @@ export async function fetchTaskImages(filters: TaskImageFilters = {}): Promise { + return fetchAPI('/v1/writer/images/content_images/'); +} + export async function deleteTaskImage(id: number): Promise { return fetchAPI(`/v1/writer/images/${id}/`, { method: 'DELETE',