Updated iamge prompt flow adn frotnend backend

This commit is contained in:
IGNY8 VPS (Salman)
2025-11-11 18:10:18 +00:00
parent fa696064e2
commit a1b21f39f6
10 changed files with 611 additions and 196 deletions

View File

@@ -151,9 +151,9 @@ class GenerateImagePromptsFunction(BaseAIFunction):
prompts_created = 0 prompts_created = 0
with transaction.atomic(): with transaction.atomic():
# Save featured image prompt # Save featured image prompt - use content instead of task
Images.objects.update_or_create( Images.objects.update_or_create(
task=content.task, content=content,
image_type='featured', image_type='featured',
defaults={ defaults={
'prompt': parsed['featured_prompt'], '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}" heading = h2_headings[idx] if idx < len(h2_headings) else f"Section {idx + 1}"
Images.objects.update_or_create( Images.objects.update_or_create(
task=content.task, content=content,
image_type='in_article', image_type='in_article',
position=idx + 1, position=idx + 1,
defaults={ defaults={

View File

@@ -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,
),
]

View File

@@ -138,7 +138,7 @@ class Content(SiteSectorBaseModel):
class Images(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 = [ IMAGE_TYPE_CHOICES = [
('featured', 'Featured Image'), ('featured', 'Featured Image'),
@@ -147,11 +147,21 @@ class Images(SiteSectorBaseModel):
('in_article', 'In-Article Image'), ('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( task = models.ForeignKey(
Tasks, Tasks,
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='images', 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_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") 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: class Meta:
db_table = 'igny8_images' db_table = 'igny8_images'
ordering = ['task', 'position', '-created_at'] ordering = ['content', 'position', '-created_at']
verbose_name = 'Image' verbose_name = 'Image'
verbose_name_plural = 'Images' verbose_name_plural = 'Images'
indexes = [ indexes = [
models.Index(fields=['content', 'image_type']),
models.Index(fields=['task', 'image_type']), models.Index(fields=['task', 'image_type']),
models.Index(fields=['status']), models.Index(fields=['status']),
models.Index(fields=['content', 'position']),
models.Index(fields=['task', 'position']), models.Index(fields=['task', 'position']),
] ]
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""Automatically set account, site, and sector from task""" """Automatically set account, site, and sector from content or task"""
if self.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.account = self.task.account
self.site = self.task.site self.site = self.task.site
self.sector = self.task.sector self.sector = self.task.sector
super().save(*args, **kwargs) super().save(*args, **kwargs)
def __str__(self): 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}"

View File

@@ -111,6 +111,7 @@ class TasksSerializer(serializers.ModelSerializer):
class ImagesSerializer(serializers.ModelSerializer): class ImagesSerializer(serializers.ModelSerializer):
"""Serializer for Images model""" """Serializer for Images model"""
task_title = serializers.SerializerMethodField() task_title = serializers.SerializerMethodField()
content_title = serializers.SerializerMethodField()
class Meta: class Meta:
model = Images model = Images
@@ -118,6 +119,8 @@ class ImagesSerializer(serializers.ModelSerializer):
'id', 'id',
'task_id', 'task_id',
'task_title', 'task_title',
'content_id',
'content_title',
'image_type', 'image_type',
'image_url', 'image_url',
'image_path', 'image_path',
@@ -140,6 +143,38 @@ class ImagesSerializer(serializers.ModelSerializer):
return None return None
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): class ContentSerializer(serializers.ModelSerializer):
"""Serializer for Content model""" """Serializer for Content model"""

View File

@@ -2,7 +2,8 @@ from rest_framework import viewsets, filters, status
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from django_filters.rest_framework import DjangoFilterBackend 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.base import SiteSectorModelViewSet
from igny8_core.api.pagination import CustomPageNumberPagination from igny8_core.api.pagination import CustomPageNumberPagination
from .models import Tasks, Images, Content from .models import Tasks, Images, Content
@@ -348,15 +349,15 @@ class TasksViewSet(SiteSectorModelViewSet):
class ImagesViewSet(SiteSectorModelViewSet): class ImagesViewSet(SiteSectorModelViewSet):
""" """
ViewSet for managing task images ViewSet for managing content images
""" """
queryset = Images.objects.all() queryset = Images.objects.all()
serializer_class = ImagesSerializer serializer_class = ImagesSerializer
filter_backends = [DjangoFilterBackend, filters.OrderingFilter] filter_backends = [DjangoFilterBackend, filters.OrderingFilter]
ordering_fields = ['created_at', 'position'] ordering_fields = ['created_at', 'position']
ordering = ['task', 'position', '-created_at'] ordering = ['content', 'position', '-created_at']
filterset_fields = ['task_id', 'image_type', 'status'] filterset_fields = ['task_id', 'content_id', 'image_type', 'status']
def perform_create(self, serializer): def perform_create(self, serializer):
"""Override to automatically set account""" """Override to automatically set account"""
@@ -433,6 +434,86 @@ class ImagesViewSet(SiteSectorModelViewSet):
'type': 'TaskError' 'type': 'TaskError'
}, status=status.HTTP_500_INTERNAL_SERVER_ERROR) }, 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): class ContentViewSet(SiteSectorModelViewSet):
""" """

View File

@@ -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 (
<div className="text-gray-400 dark:text-gray-500 text-sm">-</div>
);
}
const prompt = image.prompt || '';
const shouldTruncate = prompt.length > maxPromptLength;
const displayPrompt = showFullPrompt || !shouldTruncate ? prompt : `${prompt.substring(0, maxPromptLength)}...`;
return (
<div className="space-y-2">
{/* Prompt Text */}
{prompt && (
<div className="text-sm">
<p className="text-gray-700 dark:text-gray-300">
{displayPrompt}
{shouldTruncate && (
<button
onClick={() => setShowFullPrompt(!showFullPrompt)}
className="ml-1 text-brand-500 hover:text-brand-600 text-xs"
>
{showFullPrompt ? 'Show less' : 'Show more'}
</button>
)}
</p>
</div>
)}
{/* Image Display */}
<div className="relative">
{image.status === 'pending' && (
<div className="w-full h-24 bg-gray-200 dark:bg-gray-700 rounded border-2 border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center">
<div className="text-center">
<svg
className="w-8 h-8 mx-auto text-gray-400 dark:text-gray-500 mb-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<p className="text-xs text-gray-500 dark:text-gray-400">Pending</p>
</div>
</div>
)}
{image.status === 'generated' && image.image_url && (
<a
href={image.image_url}
target="_blank"
rel="noopener noreferrer"
className="block group"
>
<img
src={image.image_url}
alt={prompt || 'Generated image'}
className="w-full h-24 object-cover rounded border border-gray-300 dark:border-gray-600 group-hover:opacity-80 transition-opacity"
onError={(e) => {
// Fallback to placeholder if image fails to load
const target = e.target as HTMLImageElement;
target.style.display = 'none';
target.parentElement!.innerHTML = `
<div class="w-full h-24 bg-gray-200 dark:bg-gray-700 rounded border-2 border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center">
<p class="text-xs text-gray-500 dark:text-gray-400">Image not available</p>
</div>
`;
}}
/>
</a>
)}
{image.status === 'generated' && !image.image_url && (
<div className="w-full h-24 bg-yellow-100 dark:bg-yellow-900/20 rounded border border-yellow-300 dark:border-yellow-700 flex items-center justify-center">
<p className="text-xs text-yellow-700 dark:text-yellow-400">No URL available</p>
</div>
)}
{image.status === 'failed' && (
<div className="w-full h-24 bg-red-100 dark:bg-red-900/20 rounded border border-red-300 dark:border-red-700 flex items-center justify-center">
<div className="text-center">
<svg
className="w-6 h-6 mx-auto text-red-500 mb-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<p className="text-xs text-red-700 dark:text-red-400">Failed</p>
</div>
</div>
)}
{/* Status Badge */}
<div className="absolute top-1 right-1">
<Badge
color={
image.status === 'generated' ? 'success' :
image.status === 'failed' ? 'error' :
'warning'
}
size="xs"
variant="light"
>
{image.status}
</Badge>
</div>
</div>
</div>
);
}

View File

@@ -1,17 +1,13 @@
/** /**
* Images Page Configuration * 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 React from 'react';
import {
titleColumn,
statusColumn,
createdColumn,
} from '../snippets/columns.snippets';
import Badge from '../../components/ui/badge/Badge'; import Badge from '../../components/ui/badge/Badge';
import { formatRelativeDate } from '../../utils/date'; import ContentImageCell, { ContentImageData } from '../../components/common/ContentImageCell';
import { TaskImage } from '../../services/api'; import { ContentImagesGroup } from '../../services/api';
export interface ColumnConfig { export interface ColumnConfig {
key: string; key: string;
@@ -35,121 +31,112 @@ export interface HeaderMetricConfig {
label: string; label: string;
value: number; value: number;
accentColor: 'blue' | 'green' | 'amber' | 'purple'; accentColor: 'blue' | 'green' | 'amber' | 'purple';
calculate: (data: { images: any[]; totalCount: number }) => number; calculate: (data: { images: ContentImagesGroup[]; totalCount: number }) => number;
} }
export interface ImagesPageConfig { export interface ImagesPageConfig {
columns: ColumnConfig[]; columns: ColumnConfig[];
filters: FilterConfig[]; filters: FilterConfig[];
headerMetrics: HeaderMetricConfig[]; headerMetrics: HeaderMetricConfig[];
maxInArticleImages: number; // Maximum number of in-article image columns to show
} }
export const createImagesPageConfig = ( export const createImagesPageConfig = (
handlers: { handlers: {
searchTerm: string; searchTerm: string;
setSearchTerm: (value: string) => void; setSearchTerm: (value: string) => void;
imageTypeFilter: string;
setImageTypeFilter: (value: string) => void;
statusFilter: string; statusFilter: string;
setStatusFilter: (value: string) => void; setStatusFilter: (value: string) => void;
setCurrentPage: (page: number) => void; setCurrentPage: (page: number) => void;
maxInArticleImages?: number; // Optional: max in-article images to display
} }
): ImagesPageConfig => { ): ImagesPageConfig => {
return { const maxImages = handlers.maxInArticleImages || 5; // Default to 5 in-article images
columns: [
// Build columns dynamically based on max in-article images
const columns: ColumnConfig[] = [
{ {
key: 'task_title', key: 'content_title',
label: 'Task', label: 'Content Title',
sortable: false, sortable: false,
width: '250px', width: '250px',
render: (_value: string, row: TaskImage) => ( render: (_value: string, row: ContentImagesGroup) => (
<span className="font-medium text-gray-800 dark:text-white/90"> <div>
{row.task_title || '-'} <a
</span> href={`/writer/content/${row.content_id}`}
className="font-medium text-brand-500 hover:text-brand-600 dark:text-brand-400"
>
{row.content_title}
</a>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
ID: {row.content_id}
</div>
</div>
), ),
}, },
{ {
key: 'image_type', key: 'featured_image',
label: 'Image Type', label: 'Featured Image',
sortable: false,
width: '150px',
render: (value: string) => (
<Badge color="info" size="sm" variant="light">
{value?.replace('_', ' ') || '-'}
</Badge>
),
},
{
key: 'image_url',
label: 'Image',
sortable: false, sortable: false,
width: '200px', width: '200px',
render: (value: string) => { render: (_value: any, row: ContentImagesGroup) => (
if (!value) return <span className="text-gray-400">-</span>; <ContentImageCell image={row.featured_image} />
return ( ),
<a
href={value}
target="_blank"
rel="noopener noreferrer"
className="text-brand-500 hover:text-brand-600 text-sm truncate block max-w-[200px]"
>
View Image
</a>
);
}, },
];
// 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 <ContentImageCell image={image || null} />;
}, },
{ });
...statusColumn, }
sortable: true,
sortField: 'status', // Add overall status column
columns.push({
key: 'overall_status',
label: 'Status',
sortable: false,
width: '120px',
render: (value: string) => { render: (value: string) => {
const statusColors: Record<string, 'success' | 'warning' | 'error'> = { const statusColors: Record<string, 'success' | 'warning' | 'error' | 'info'> = {
'complete': 'success',
'partial': 'info',
'pending': 'warning', 'pending': 'warning',
'generated': 'success',
'failed': 'error', 'failed': 'error',
}; };
const labels: Record<string, string> = {
'complete': 'Complete',
'partial': 'Partial',
'pending': 'Pending',
'failed': 'Failed',
};
return ( return (
<Badge <Badge
color={statusColors[value] || 'warning'} color={statusColors[value] || 'warning'}
size="sm" size="sm"
> >
{value} {labels[value] || value}
</Badge> </Badge>
); );
}, },
}, });
{
key: 'position', return {
label: 'Position', columns,
sortable: false,
width: '100px',
render: (value: number) => value || 0,
},
{
...createdColumn,
sortable: true,
sortField: 'created_at',
render: (value: string) => formatRelativeDate(value),
},
],
filters: [ filters: [
{ {
key: 'search', key: 'search',
label: 'Search', label: 'Search',
type: 'text', type: 'text',
placeholder: 'Search by task title...', placeholder: 'Search by content 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' },
],
}, },
{ {
key: 'status', key: 'status',
@@ -157,38 +144,39 @@ export const createImagesPageConfig = (
type: 'select', type: 'select',
options: [ options: [
{ value: '', label: 'All Status' }, { value: '', label: 'All Status' },
{ value: 'complete', label: 'Complete' },
{ value: 'partial', label: 'Partial' },
{ value: 'pending', label: 'Pending' }, { value: 'pending', label: 'Pending' },
{ value: 'generated', label: 'Generated' },
{ value: 'failed', label: 'Failed' }, { value: 'failed', label: 'Failed' },
], ],
}, },
], ],
headerMetrics: [ headerMetrics: [
{ {
label: 'Total Images', label: 'Total Content',
value: 0, value: 0,
accentColor: 'blue' as const, accentColor: 'blue' as const,
calculate: (data) => data.totalCount || 0, calculate: (data) => data.totalCount || 0,
}, },
{ {
label: 'Generated', label: 'Complete',
value: 0, value: 0,
accentColor: 'green' as const, 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', label: 'Pending',
value: 0, value: 0,
accentColor: 'amber' as const, accentColor: 'amber' as const,
calculate: (data) => data.images.filter((i: TaskImage) => i.status === 'pending').length, calculate: (data) => data.images.filter((i: ContentImagesGroup) => i.overall_status === 'pending').length,
},
{
label: 'Failed',
value: 0,
accentColor: 'error' as const,
calculate: (data) => data.images.filter((i: TaskImage) => i.status === 'failed').length,
}, },
], ],
maxInArticleImages: maxImages,
}; };
}; };

View File

@@ -3,7 +3,7 @@
* Displays content from Content table with filters and pagination * 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 TablePageTemplate from '../../templates/TablePageTemplate';
import { import {
fetchContent, fetchContent,
@@ -16,6 +16,8 @@ import { FileIcon } from '../../icons';
import { createContentPageConfig } from '../../config/pages/content.config'; import { createContentPageConfig } from '../../config/pages/content.config';
import { useSectorStore } from '../../store/sectorStore'; import { useSectorStore } from '../../store/sectorStore';
import { usePageSizeStore } from '../../store/pageSizeStore'; import { usePageSizeStore } from '../../store/pageSizeStore';
import ProgressModal from '../../components/common/ProgressModal';
import { useProgressModal } from '../../hooks/useProgressModal';
export default function Content() { export default function Content() {
const toast = useToast(); const toast = useToast();
@@ -41,6 +43,10 @@ export default function Content() {
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [showContent, setShowContent] = useState(false); const [showContent, setShowContent] = useState(false);
// Progress modal for AI functions
const progressModal = useProgressModal();
const hasReloadedRef = useRef(false);
// Load content - wrapped in useCallback // Load content - wrapped in useCallback
const loadContent = useCallback(async () => { const loadContent = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -152,9 +158,16 @@ export default function Content() {
const result = await generateImagePrompts([row.id]); const result = await generateImagePrompts([row.id]);
if (result.success) { if (result.success) {
if (result.task_id) { 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 { } else {
// Synchronous completion
toast.success(`Image prompts generated: ${result.prompts_created || 0} prompt${(result.prompts_created || 0) === 1 ? '' : 's'} created`); toast.success(`Image prompts generated: ${result.prompts_created || 0} prompt${(result.prompts_created || 0) === 1 ? '' : 's'} created`);
loadContent(); // Reload to show new prompts
} }
} else { } else {
toast.error(result.error || 'Failed to generate image prompts'); 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.error(`Failed to generate prompts: ${error.message}`);
} }
} }
}, [toast]); }, [toast, progressModal, loadContent]);
return ( return (
<> <>
@@ -207,6 +220,30 @@ export default function Content() {
onRowAction={handleRowAction} onRowAction={handleRowAction}
getItemDisplayName={(row: ContentType) => row.meta_title || row.title || `Content #${row.id}`} getItemDisplayName={(row: ContentType) => row.meta_title || row.title || `Content #${row.id}`}
/> />
{/* Progress Modal for AI Functions */}
<ProgressModal
isOpen={progressModal.isOpen}
title={progressModal.title}
percentage={progressModal.progress.percentage}
status={progressModal.progress.status}
message={progressModal.progress.message}
details={progressModal.progress.details}
taskId={progressModal.taskId || undefined}
functionId={progressModal.functionId}
onClose={() => {
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);
}
}}
/>
</> </>
); );
} }

View File

@@ -1,17 +1,14 @@
/** /**
* Images Page - Built with TablePageTemplate * 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 { useState, useEffect, useMemo, useCallback } from 'react';
import TablePageTemplate from '../../templates/TablePageTemplate'; import TablePageTemplate from '../../templates/TablePageTemplate';
import { import {
fetchTaskImages, fetchContentImages,
deleteTaskImage, ContentImagesGroup,
bulkDeleteTaskImages, ContentImagesResponse,
autoGenerateImages,
TaskImage,
TaskImageFilters,
} from '../../services/api'; } from '../../services/api';
import { useToast } from '../../components/ui/toast/ToastContainer'; import { useToast } from '../../components/ui/toast/ToastContainer';
import { FileIcon, DownloadIcon } from '../../icons'; import { FileIcon, DownloadIcon } from '../../icons';
@@ -21,23 +18,23 @@ export default function Images() {
const toast = useToast(); const toast = useToast();
// Data state // Data state
const [images, setImages] = useState<TaskImage[]>([]); const [images, setImages] = useState<ContentImagesGroup[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Filter state // Filter state
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [imageTypeFilter, setImageTypeFilter] = useState('');
const [statusFilter, setStatusFilter] = useState(''); const [statusFilter, setStatusFilter] = useState('');
const [selectedIds, setSelectedIds] = useState<string[]>([]); const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Pagination state // Pagination state (client-side for now)
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0); const [totalCount, setTotalCount] = useState(0);
const pageSize = 10;
// Sorting state // Sorting state
const [sortBy, setSortBy] = useState<string>('created_at'); const [sortBy, setSortBy] = useState<string>('content_title');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [showContent, setShowContent] = useState(false); const [showContent, setShowContent] = useState(false);
// Load images - wrapped in useCallback // Load images - wrapped in useCallback
@@ -45,30 +42,46 @@ export default function Images() {
setLoading(true); setLoading(true);
setShowContent(false); setShowContent(false);
try { try {
const ordering = sortBy ? `${sortDirection === 'desc' ? '-' : ''}${sortBy}` : '-created_at'; const data: ContentImagesResponse = await fetchContentImages();
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);
let filteredResults = data.results || []; let filteredResults = data.results || [];
// Client-side search filter // Client-side search filter
if (searchTerm) { if (searchTerm) {
filteredResults = filteredResults.filter(img => filteredResults = filteredResults.filter(group =>
img.task_title?.toLowerCase().includes(searchTerm.toLowerCase()) 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); setTotalCount(filteredResults.length);
setTotalPages(Math.ceil(filteredResults.length / 10)); setTotalPages(Math.ceil(filteredResults.length / pageSize));
setTimeout(() => { setTimeout(() => {
setShowContent(true); setShowContent(true);
@@ -80,7 +93,7 @@ export default function Images() {
setShowContent(true); setShowContent(true);
setLoading(false); setLoading(false);
} }
}, [currentPage, imageTypeFilter, statusFilter, sortBy, sortDirection, searchTerm]); }, [currentPage, statusFilter, sortBy, sortDirection, searchTerm, toast]);
useEffect(() => { useEffect(() => {
loadImages(); loadImages();
@@ -101,7 +114,7 @@ export default function Images() {
// Handle sorting // Handle sorting
const handleSort = (field: string, direction: 'asc' | 'desc') => { const handleSort = (field: string, direction: 'asc' | 'desc') => {
setSortBy(field || 'created_at'); setSortBy(field || 'content_title');
setSortDirection(direction); setSortDirection(direction);
setCurrentPage(1); setCurrentPage(1);
}; };
@@ -116,37 +129,31 @@ export default function Images() {
} catch (error: any) { } catch (error: any) {
throw error; throw error;
} }
}, []); }, [toast]);
// Bulk action handler // Bulk action handler
const handleBulkAction = useCallback(async (action: string, ids: string[]) => { 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 // Create page config
const pageConfig = useMemo(() => { const pageConfig = useMemo(() => {
return createImagesPageConfig({ return createImagesPageConfig({
searchTerm, searchTerm,
setSearchTerm, setSearchTerm,
imageTypeFilter,
setImageTypeFilter,
statusFilter, statusFilter,
setStatusFilter, setStatusFilter,
setCurrentPage, setCurrentPage,
maxInArticleImages,
}); });
}, [searchTerm, imageTypeFilter, statusFilter]); }, [searchTerm, statusFilter, maxInArticleImages]);
// Calculate header metrics // Calculate header metrics
const headerMetrics = useMemo(() => { const headerMetrics = useMemo(() => {
@@ -160,9 +167,9 @@ export default function Images() {
return ( return (
<TablePageTemplate <TablePageTemplate
title="Task Images" title="Content Images"
titleIcon={<FileIcon className="text-purple-500 size-5" />} titleIcon={<FileIcon className="text-purple-500 size-5" />}
subtitle="Manage images for content tasks" subtitle="Manage images for content articles"
columns={pageConfig.columns} columns={pageConfig.columns}
data={images} data={images}
loading={loading} loading={loading}
@@ -170,40 +177,25 @@ export default function Images() {
filters={pageConfig.filters} filters={pageConfig.filters}
filterValues={{ filterValues={{
search: searchTerm, search: searchTerm,
image_type: imageTypeFilter,
status: statusFilter, status: statusFilter,
}} }}
onFilterChange={(key, value) => { onFilterChange={(key, value) => {
const stringValue = value === null || value === undefined ? '' : String(value); const stringValue = value === null || value === undefined ? '' : String(value);
if (key === 'search') { if (key === 'search') {
setSearchTerm(stringValue); setSearchTerm(stringValue);
} else if (key === 'image_type') {
setImageTypeFilter(stringValue);
} else if (key === 'status') { } else if (key === 'status') {
setStatusFilter(stringValue); setStatusFilter(stringValue);
} }
setCurrentPage(1); 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} onBulkExport={handleBulkExport}
onBulkAction={handleBulkAction} onBulkAction={handleBulkAction}
getItemDisplayName={(row: TaskImage) => row.task_title || `Image ${row.id}`} getItemDisplayName={(row: ContentImagesGroup) => row.content_title || `Content #${row.content_id}`}
onExport={async () => { onExport={async () => {
toast.info('Export functionality coming soon'); toast.info('Export functionality coming soon');
}} }}
onExportIcon={<DownloadIcon />} onExportIcon={<DownloadIcon />}
selectionLabel="image" selectionLabel="content"
pagination={{ pagination={{
currentPage, currentPage,
totalPages, totalPages,
@@ -222,7 +214,6 @@ export default function Images() {
headerMetrics={headerMetrics} headerMetrics={headerMetrics}
onFilterReset={() => { onFilterReset={() => {
setSearchTerm(''); setSearchTerm('');
setImageTypeFilter('');
setStatusFilter(''); setStatusFilter('');
setCurrentPage(1); setCurrentPage(1);
}} }}

View File

@@ -1002,6 +1002,36 @@ export async function fetchTaskImages(filters: TaskImageFilters = {}): Promise<T
return fetchAPI(endpoint); return fetchAPI(endpoint);
} }
// Content Images (grouped by content)
export interface ContentImage {
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;
}
export interface ContentImagesGroup {
content_id: number;
content_title: string;
featured_image: ContentImage | null;
in_article_images: ContentImage[];
overall_status: 'pending' | 'partial' | 'complete' | 'failed';
}
export interface ContentImagesResponse {
count: number;
results: ContentImagesGroup[];
}
export async function fetchContentImages(): Promise<ContentImagesResponse> {
return fetchAPI('/v1/writer/images/content_images/');
}
export async function deleteTaskImage(id: number): Promise<void> { export async function deleteTaskImage(id: number): Promise<void> {
return fetchAPI(`/v1/writer/images/${id}/`, { return fetchAPI(`/v1/writer/images/${id}/`, {
method: 'DELETE', method: 'DELETE',