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 */}
+
+
+ );
+}
+
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) => (
+
+ ),
+ },
+ {
+ 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',