Updated iamge prompt flow adn frotnend backend
This commit is contained in:
@@ -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={
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
@@ -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}"
|
||||||
|
|
||||||
|
|||||||
@@ -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"""
|
||||||
|
|||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
150
frontend/src/components/common/ContentImageCell.tsx
Normal file
150
frontend/src/components/common/ContentImageCell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user