fine tuning
This commit is contained in:
@@ -161,6 +161,7 @@ class GenerateContentFunction(BaseAIFunction):
|
|||||||
"""
|
"""
|
||||||
STAGE 3: Save content using final Stage 1 Content model schema.
|
STAGE 3: Save content using final Stage 1 Content model schema.
|
||||||
Creates independent Content record (no OneToOne to Task).
|
Creates independent Content record (no OneToOne to Task).
|
||||||
|
Handles tags and categories from AI response.
|
||||||
"""
|
"""
|
||||||
if isinstance(original_data, list):
|
if isinstance(original_data, list):
|
||||||
task = original_data[0] if original_data else None
|
task = original_data[0] if original_data else None
|
||||||
@@ -179,6 +180,9 @@ class GenerateContentFunction(BaseAIFunction):
|
|||||||
meta_description = parsed.get('meta_description') or parsed.get('seo_description')
|
meta_description = parsed.get('meta_description') or parsed.get('seo_description')
|
||||||
primary_keyword = parsed.get('primary_keyword') or parsed.get('focus_keyword')
|
primary_keyword = parsed.get('primary_keyword') or parsed.get('focus_keyword')
|
||||||
secondary_keywords = parsed.get('secondary_keywords') or parsed.get('keywords', [])
|
secondary_keywords = parsed.get('secondary_keywords') or parsed.get('keywords', [])
|
||||||
|
# Extract tags and categories from AI response
|
||||||
|
tags_from_response = parsed.get('tags', [])
|
||||||
|
categories_from_response = parsed.get('categories', [])
|
||||||
else:
|
else:
|
||||||
# Plain text response
|
# Plain text response
|
||||||
content_html = str(parsed)
|
content_html = str(parsed)
|
||||||
@@ -187,6 +191,8 @@ class GenerateContentFunction(BaseAIFunction):
|
|||||||
meta_description = None
|
meta_description = None
|
||||||
primary_keyword = None
|
primary_keyword = None
|
||||||
secondary_keywords = []
|
secondary_keywords = []
|
||||||
|
tags_from_response = []
|
||||||
|
categories_from_response = []
|
||||||
|
|
||||||
# Calculate word count
|
# Calculate word count
|
||||||
word_count = 0
|
word_count = 0
|
||||||
@@ -222,8 +228,51 @@ class GenerateContentFunction(BaseAIFunction):
|
|||||||
if task.taxonomy_term:
|
if task.taxonomy_term:
|
||||||
content_record.taxonomy_terms.add(task.taxonomy_term)
|
content_record.taxonomy_terms.add(task.taxonomy_term)
|
||||||
|
|
||||||
# Link all keywords from task as taxonomy terms (if they have taxonomy mappings)
|
# Process tags from AI response
|
||||||
# This is optional - keywords are M2M on Task, not directly on Content
|
if tags_from_response and isinstance(tags_from_response, list):
|
||||||
|
from django.utils.text import slugify
|
||||||
|
for tag_name in tags_from_response:
|
||||||
|
if tag_name and isinstance(tag_name, str):
|
||||||
|
tag_name = tag_name.strip()
|
||||||
|
if tag_name:
|
||||||
|
try:
|
||||||
|
# Get or create tag taxonomy term
|
||||||
|
tag_obj, _ = ContentTaxonomy.objects.get_or_create(
|
||||||
|
site=task.site,
|
||||||
|
name=tag_name,
|
||||||
|
taxonomy_type='tag',
|
||||||
|
defaults={
|
||||||
|
'slug': slugify(tag_name),
|
||||||
|
'sector': task.sector,
|
||||||
|
'account': task.account,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
content_record.taxonomy_terms.add(tag_obj)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to add tag '{tag_name}': {e}")
|
||||||
|
|
||||||
|
# Process categories from AI response
|
||||||
|
if categories_from_response and isinstance(categories_from_response, list):
|
||||||
|
from django.utils.text import slugify
|
||||||
|
for category_name in categories_from_response:
|
||||||
|
if category_name and isinstance(category_name, str):
|
||||||
|
category_name = category_name.strip()
|
||||||
|
if category_name:
|
||||||
|
try:
|
||||||
|
# Get or create category taxonomy term
|
||||||
|
category_obj, _ = ContentTaxonomy.objects.get_or_create(
|
||||||
|
site=task.site,
|
||||||
|
name=category_name,
|
||||||
|
taxonomy_type='category',
|
||||||
|
defaults={
|
||||||
|
'slug': slugify(category_name),
|
||||||
|
'sector': task.sector,
|
||||||
|
'account': task.account,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
content_record.taxonomy_terms.add(category_obj)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to add category '{category_name}': {e}")
|
||||||
|
|
||||||
# STAGE 3: Update task status to completed
|
# STAGE 3: Update task status to completed
|
||||||
task.status = 'completed'
|
task.status = 'completed'
|
||||||
|
|||||||
@@ -707,6 +707,25 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
|
|||||||
})
|
})
|
||||||
failed += 1
|
failed += 1
|
||||||
|
|
||||||
|
# Check if all images for the content are generated and update status to 'review'
|
||||||
|
if content_id and completed > 0:
|
||||||
|
try:
|
||||||
|
from igny8_core.business.content.models import Content, Images
|
||||||
|
|
||||||
|
content = Content.objects.get(id=content_id)
|
||||||
|
|
||||||
|
# Check if all images for this content are now generated
|
||||||
|
all_images = Images.objects.filter(content=content)
|
||||||
|
pending_images = all_images.filter(status='pending').count()
|
||||||
|
|
||||||
|
# If no pending images and content is still in draft, move to review
|
||||||
|
if pending_images == 0 and content.status == 'draft':
|
||||||
|
content.status = 'review'
|
||||||
|
content.save(update_fields=['status'])
|
||||||
|
logger.info(f"[process_image_generation_queue] Content #{content_id} status updated to 'review' (all images generated)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[process_image_generation_queue] Error updating content status: {str(e)}", exc_info=True)
|
||||||
|
|
||||||
# Final state
|
# Final state
|
||||||
logger.info("=" * 80)
|
logger.info("=" * 80)
|
||||||
logger.info(f"process_image_generation_queue COMPLETED")
|
logger.info(f"process_image_generation_queue COMPLETED")
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ class Content(SiteSectorBaseModel):
|
|||||||
# Status tracking
|
# Status tracking
|
||||||
STATUS_CHOICES = [
|
STATUS_CHOICES = [
|
||||||
('draft', 'Draft'),
|
('draft', 'Draft'),
|
||||||
|
('review', 'Review'),
|
||||||
('published', 'Published'),
|
('published', 'Published'),
|
||||||
]
|
]
|
||||||
status = models.CharField(
|
status = models.CharField(
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Generated manually on 2025-11-28
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('writer', '0009_add_word_count_to_tasks'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='content',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
('draft', 'Draft'),
|
||||||
|
('review', 'Review'),
|
||||||
|
('published', 'Published')
|
||||||
|
],
|
||||||
|
db_index=True,
|
||||||
|
default='draft',
|
||||||
|
help_text='Content status',
|
||||||
|
max_length=50
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -154,6 +154,9 @@ class ContentSerializer(serializers.ModelSerializer):
|
|||||||
cluster_name = serializers.SerializerMethodField()
|
cluster_name = serializers.SerializerMethodField()
|
||||||
sector_name = serializers.SerializerMethodField()
|
sector_name = serializers.SerializerMethodField()
|
||||||
taxonomy_terms_data = serializers.SerializerMethodField()
|
taxonomy_terms_data = serializers.SerializerMethodField()
|
||||||
|
has_image_prompts = serializers.SerializerMethodField()
|
||||||
|
image_status = serializers.SerializerMethodField()
|
||||||
|
has_generated_images = serializers.SerializerMethodField()
|
||||||
site_id = serializers.IntegerField(write_only=True, required=False)
|
site_id = serializers.IntegerField(write_only=True, required=False)
|
||||||
sector_id = serializers.IntegerField(write_only=True, required=False)
|
sector_id = serializers.IntegerField(write_only=True, required=False)
|
||||||
|
|
||||||
@@ -181,6 +184,9 @@ class ContentSerializer(serializers.ModelSerializer):
|
|||||||
'site_id',
|
'site_id',
|
||||||
'sector_id',
|
'sector_id',
|
||||||
'account_id',
|
'account_id',
|
||||||
|
'has_image_prompts',
|
||||||
|
'image_status',
|
||||||
|
'has_generated_images',
|
||||||
'created_at',
|
'created_at',
|
||||||
'updated_at',
|
'updated_at',
|
||||||
]
|
]
|
||||||
@@ -240,6 +246,35 @@ class ContentSerializer(serializers.ModelSerializer):
|
|||||||
for term in obj.taxonomy_terms.all()
|
for term in obj.taxonomy_terms.all()
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def get_has_image_prompts(self, obj):
|
||||||
|
"""Check if content has any image prompts (images with prompts)"""
|
||||||
|
return obj.images.filter(prompt__isnull=False).exclude(prompt='').exists()
|
||||||
|
|
||||||
|
def get_image_status(self, obj):
|
||||||
|
"""Get image generation status: 'generated', 'pending', 'failed', or None"""
|
||||||
|
images = obj.images.all()
|
||||||
|
if not images.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check statuses
|
||||||
|
has_failed = images.filter(status='failed').exists()
|
||||||
|
has_generated = images.filter(status='generated').exists()
|
||||||
|
has_pending = images.filter(status='pending').exists()
|
||||||
|
|
||||||
|
# Priority: failed > pending > generated
|
||||||
|
if has_failed:
|
||||||
|
return 'failed'
|
||||||
|
elif has_pending:
|
||||||
|
return 'pending'
|
||||||
|
elif has_generated:
|
||||||
|
return 'generated'
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_has_generated_images(self, obj):
|
||||||
|
"""Check if content has any successfully generated images"""
|
||||||
|
return obj.images.filter(status='generated', image_url__isnull=False).exclude(image_url='').exists()
|
||||||
|
|
||||||
|
|
||||||
class ContentTaxonomySerializer(serializers.ModelSerializer):
|
class ContentTaxonomySerializer(serializers.ModelSerializer):
|
||||||
"""Serializer for ContentTaxonomy model - Stage 1 refactored"""
|
"""Serializer for ContentTaxonomy model - Stage 1 refactored"""
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const Content = lazy(() => import("./pages/Writer/Content"));
|
|||||||
const ContentView = lazy(() => import("./pages/Writer/ContentView"));
|
const ContentView = lazy(() => import("./pages/Writer/ContentView"));
|
||||||
const Drafts = lazy(() => import("./pages/Writer/Drafts"));
|
const Drafts = lazy(() => import("./pages/Writer/Drafts"));
|
||||||
const Images = lazy(() => import("./pages/Writer/Images"));
|
const Images = lazy(() => import("./pages/Writer/Images"));
|
||||||
|
const Review = lazy(() => import("./pages/Writer/Review"));
|
||||||
const Published = lazy(() => import("./pages/Writer/Published"));
|
const Published = lazy(() => import("./pages/Writer/Published"));
|
||||||
|
|
||||||
// Linker Module - Lazy loaded
|
// Linker Module - Lazy loaded
|
||||||
@@ -242,6 +243,13 @@ export default function App() {
|
|||||||
</ModuleGuard>
|
</ModuleGuard>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/writer/review" element={
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ModuleGuard module="writer">
|
||||||
|
<Review />
|
||||||
|
</ModuleGuard>
|
||||||
|
</Suspense>
|
||||||
|
} />
|
||||||
<Route path="/writer/published" element={
|
<Route path="/writer/published" element={
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<ModuleGuard module="writer">
|
<ModuleGuard module="writer">
|
||||||
|
|||||||
224
frontend/src/config/pages/review.config.tsx
Normal file
224
frontend/src/config/pages/review.config.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
/**
|
||||||
|
* Review Page Configuration
|
||||||
|
* Centralized config for Review page table, filters, and actions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Content } from '../../services/api';
|
||||||
|
import Badge from '../../components/ui/badge/Badge';
|
||||||
|
import { formatRelativeDate } from '../../utils/date';
|
||||||
|
import { CheckCircleIcon } from '../../icons';
|
||||||
|
import { STRUCTURE_LABELS, TYPE_LABELS } from '../structureMapping';
|
||||||
|
|
||||||
|
export interface ColumnConfig {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
sortable?: boolean;
|
||||||
|
sortField?: string;
|
||||||
|
align?: 'left' | 'center' | 'right';
|
||||||
|
width?: string;
|
||||||
|
numeric?: boolean;
|
||||||
|
date?: boolean;
|
||||||
|
render?: (value: any, row: any) => React.ReactNode;
|
||||||
|
toggleable?: boolean;
|
||||||
|
toggleContentKey?: string;
|
||||||
|
toggleContentLabel?: string;
|
||||||
|
defaultVisible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterConfig {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: 'text' | 'select';
|
||||||
|
placeholder?: string;
|
||||||
|
options?: Array<{ value: string; label: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeaderMetricConfig {
|
||||||
|
label: string;
|
||||||
|
accentColor: 'blue' | 'green' | 'amber' | 'purple';
|
||||||
|
calculate: (data: { content: Content[]; totalCount: number }) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewPageConfig {
|
||||||
|
columns: ColumnConfig[];
|
||||||
|
filters: FilterConfig[];
|
||||||
|
headerMetrics: HeaderMetricConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createReviewPageConfig(params: {
|
||||||
|
searchTerm: string;
|
||||||
|
setSearchTerm: (value: string) => void;
|
||||||
|
statusFilter: string;
|
||||||
|
setStatusFilter: (value: string) => void;
|
||||||
|
setCurrentPage: (page: number) => void;
|
||||||
|
activeSector: { id: number; name: string } | null;
|
||||||
|
}): ReviewPageConfig {
|
||||||
|
const showSectorColumn = !params.activeSector;
|
||||||
|
|
||||||
|
const columns: ColumnConfig[] = [
|
||||||
|
{
|
||||||
|
key: 'categories',
|
||||||
|
label: 'Categories',
|
||||||
|
sortable: false,
|
||||||
|
width: '180px',
|
||||||
|
render: (_value: any, row: Content) => {
|
||||||
|
const categories = row.taxonomy_terms_data?.filter((t: any) => t.taxonomy_type === 'category') || [];
|
||||||
|
if (!categories.length) return <span className="text-gray-400 dark:text-gray-500">-</span>;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{categories.map((cat: any) => (
|
||||||
|
<span key={cat.id} className="px-2 py-0.5 bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 rounded-full text-xs font-medium">{cat.name}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
toggleable: true,
|
||||||
|
defaultVisible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'tags',
|
||||||
|
label: 'Tags',
|
||||||
|
sortable: false,
|
||||||
|
width: '180px',
|
||||||
|
render: (_value: any, row: Content) => {
|
||||||
|
const tags = row.taxonomy_terms_data?.filter((t: any) => t.taxonomy_type === 'tag') || [];
|
||||||
|
if (!tags.length) return <span className="text-gray-400 dark:text-gray-500">-</span>;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{tags.map((tag: any) => (
|
||||||
|
<span key={tag.id} className="px-2 py-0.5 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300 rounded-full text-xs font-medium">{tag.name}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
toggleable: true,
|
||||||
|
defaultVisible: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'title',
|
||||||
|
label: 'Title',
|
||||||
|
sortable: true,
|
||||||
|
sortField: 'title',
|
||||||
|
toggleable: true,
|
||||||
|
toggleContentKey: 'content_html',
|
||||||
|
toggleContentLabel: 'Generated Content',
|
||||||
|
render: (value: string, row: Content) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-gray-900 dark:text-white">
|
||||||
|
{value || `Content #${row.id}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'content_type',
|
||||||
|
label: 'Type',
|
||||||
|
sortable: true,
|
||||||
|
sortField: 'content_type',
|
||||||
|
width: '120px',
|
||||||
|
render: (value: string) => (
|
||||||
|
<Badge color="primary" size="sm" variant="light">
|
||||||
|
{TYPE_LABELS[value] || value || '-'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'content_structure',
|
||||||
|
label: 'Structure',
|
||||||
|
sortable: true,
|
||||||
|
sortField: 'content_structure',
|
||||||
|
width: '150px',
|
||||||
|
render: (value: string) => (
|
||||||
|
<Badge color="info" size="sm" variant="light">
|
||||||
|
{STRUCTURE_LABELS[value] || value || '-'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cluster_name',
|
||||||
|
label: 'Cluster',
|
||||||
|
sortable: false,
|
||||||
|
width: '150px',
|
||||||
|
render: (_value: any, row: Content) => {
|
||||||
|
const clusterName = row.cluster_name;
|
||||||
|
if (!clusterName) {
|
||||||
|
return <span className="text-gray-400 dark:text-gray-500">-</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Badge color="primary" size="sm" variant="light">
|
||||||
|
{clusterName}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'word_count',
|
||||||
|
label: 'Words',
|
||||||
|
sortable: true,
|
||||||
|
sortField: 'word_count',
|
||||||
|
numeric: true,
|
||||||
|
width: '100px',
|
||||||
|
render: (value: number) => (
|
||||||
|
<span className="font-mono text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{value?.toLocaleString() || 0}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'created_at',
|
||||||
|
label: 'Created',
|
||||||
|
sortable: true,
|
||||||
|
sortField: 'created_at',
|
||||||
|
date: true,
|
||||||
|
align: 'right',
|
||||||
|
render: (value: string) => (
|
||||||
|
<span className="text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||||
|
{formatRelativeDate(value)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (showSectorColumn) {
|
||||||
|
columns.splice(4, 0, {
|
||||||
|
key: 'sector_name',
|
||||||
|
label: 'Sector',
|
||||||
|
sortable: false,
|
||||||
|
width: '120px',
|
||||||
|
render: (value: string, row: Content) => (
|
||||||
|
<Badge color="info" size="sm" variant="light">
|
||||||
|
{row.sector_name || '-'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
columns,
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
key: 'search',
|
||||||
|
label: 'Search',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Search content...',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
headerMetrics: [
|
||||||
|
{
|
||||||
|
label: 'Total Ready',
|
||||||
|
accentColor: 'blue',
|
||||||
|
calculate: ({ totalCount }) => totalCount,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Has Images',
|
||||||
|
accentColor: 'green',
|
||||||
|
calculate: ({ content }) => content.filter(c => c.has_generated_images).length,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Optimized',
|
||||||
|
accentColor: 'purple',
|
||||||
|
calculate: ({ content }) => content.filter(c => (c as any).optimization_score >= 80).length,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -341,18 +341,6 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
|
|||||||
},
|
},
|
||||||
'/writer/images': {
|
'/writer/images': {
|
||||||
rowActions: [
|
rowActions: [
|
||||||
{
|
|
||||||
key: 'publish_wordpress',
|
|
||||||
label: 'Publish to WordPress',
|
|
||||||
icon: <ArrowRightIcon className="w-5 h-5" />,
|
|
||||||
variant: 'success',
|
|
||||||
shouldShow: (row: any) => {
|
|
||||||
// Only show if content is completed and not already published to WordPress
|
|
||||||
return row.status === 'published' &&
|
|
||||||
(!row.external_id || !row.external_url) &&
|
|
||||||
(!row.sync_status || row.sync_status !== 'published');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'update_status',
|
key: 'update_status',
|
||||||
label: 'Update Status',
|
label: 'Update Status',
|
||||||
@@ -360,10 +348,21 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
|
|||||||
variant: 'primary',
|
variant: 'primary',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
bulkActions: [],
|
||||||
|
},
|
||||||
|
'/writer/review': {
|
||||||
|
rowActions: [
|
||||||
|
{
|
||||||
|
key: 'publish_wordpress',
|
||||||
|
label: 'Publish to WordPress',
|
||||||
|
icon: <ArrowRightIcon className="w-5 h-5" />,
|
||||||
|
variant: 'success',
|
||||||
|
},
|
||||||
|
],
|
||||||
bulkActions: [
|
bulkActions: [
|
||||||
{
|
{
|
||||||
key: 'bulk_publish_wordpress',
|
key: 'bulk_publish_wordpress',
|
||||||
label: 'Publish Ready to WordPress',
|
label: 'Publish to WordPress',
|
||||||
icon: <ArrowRightIcon className="w-5 h-5" />,
|
icon: <ArrowRightIcon className="w-5 h-5" />,
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
},
|
},
|
||||||
@@ -376,3 +375,4 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { Card } from '../../components/ui/card';
|
|||||||
import Button from '../../components/ui/button/Button';
|
import Button from '../../components/ui/button/Button';
|
||||||
import Checkbox from '../../components/form/input/Checkbox';
|
import Checkbox from '../../components/form/input/Checkbox';
|
||||||
import Label from '../../components/form/Label';
|
import Label from '../../components/form/Label';
|
||||||
|
import Input from '../../components/form/input/Input';
|
||||||
|
import Select from '../../components/form/input/Select';
|
||||||
import PublishingRules, { PublishingRule } from '../../components/publishing/PublishingRules';
|
import PublishingRules, { PublishingRule } from '../../components/publishing/PublishingRules';
|
||||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||||
import { fetchAPI } from '../../services/api';
|
import { fetchAPI } from '../../services/api';
|
||||||
@@ -18,6 +20,9 @@ export default function Publishing() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [defaultDestinations, setDefaultDestinations] = useState<string[]>(['sites']);
|
const [defaultDestinations, setDefaultDestinations] = useState<string[]>(['sites']);
|
||||||
const [autoPublishEnabled, setAutoPublishEnabled] = useState(false);
|
const [autoPublishEnabled, setAutoPublishEnabled] = useState(false);
|
||||||
|
const [autoSyncEnabled, setAutoSyncEnabled] = useState(false);
|
||||||
|
const [syncInterval, setSyncInterval] = useState<number>(60); // Default 60 minutes
|
||||||
|
const [syncIntervalUnit, setSyncIntervalUnit] = useState<'minutes' | 'hours'>('minutes');
|
||||||
const [publishingRules, setPublishingRules] = useState<PublishingRule[]>([]);
|
const [publishingRules, setPublishingRules] = useState<PublishingRule[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -31,6 +36,9 @@ export default function Publishing() {
|
|||||||
// For now, use defaults
|
// For now, use defaults
|
||||||
setDefaultDestinations(['sites']);
|
setDefaultDestinations(['sites']);
|
||||||
setAutoPublishEnabled(false);
|
setAutoPublishEnabled(false);
|
||||||
|
setAutoSyncEnabled(false);
|
||||||
|
setSyncInterval(60);
|
||||||
|
setSyncIntervalUnit('minutes');
|
||||||
setPublishingRules([]);
|
setPublishingRules([]);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(`Failed to load settings: ${error.message}`);
|
toast.error(`Failed to load settings: ${error.message}`);
|
||||||
@@ -147,6 +155,67 @@ export default function Publishing() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Auto-Sync Settings */}
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||||
|
Auto-Sync Settings
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Configure automatic content synchronization with publishing platforms
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={autoSyncEnabled}
|
||||||
|
onChange={(e) => setAutoSyncEnabled(e.target.checked)}
|
||||||
|
label="Enable auto-sync"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{autoSyncEnabled && (
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>Sync Interval</Label>
|
||||||
|
<div className="flex gap-3 items-center mt-2">
|
||||||
|
<div className="w-32">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max={syncIntervalUnit === 'minutes' ? 1440 : 24}
|
||||||
|
value={syncInterval}
|
||||||
|
onChange={(e) => setSyncInterval(parseInt(e.target.value) || 1)}
|
||||||
|
placeholder="60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-32">
|
||||||
|
<Select
|
||||||
|
value={syncIntervalUnit}
|
||||||
|
onChange={(e) => setSyncIntervalUnit(e.target.value as 'minutes' | 'hours')}
|
||||||
|
>
|
||||||
|
<option value="minutes">Minutes</option>
|
||||||
|
<option value="hours">Hours</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
How often the system should check for and publish ready content
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||||
|
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||||
|
<strong>Note:</strong> Content will be automatically published every{' '}
|
||||||
|
{syncInterval} {syncIntervalUnit} if it has status "review" and all images are generated.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Publishing Rules */}
|
{/* Publishing Rules */}
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<PublishingRules rules={publishingRules} onChange={setPublishingRules} />
|
<PublishingRules rules={publishingRules} onChange={setPublishingRules} />
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ export default function Content() {
|
|||||||
{ label: 'Tasks', path: '/writer/tasks', icon: <TaskIcon /> },
|
{ label: 'Tasks', path: '/writer/tasks', icon: <TaskIcon /> },
|
||||||
{ label: 'Content', path: '/writer/content', icon: <FileIcon /> },
|
{ label: 'Content', path: '/writer/content', icon: <FileIcon /> },
|
||||||
{ label: 'Images', path: '/writer/images', icon: <ImageIcon /> },
|
{ label: 'Images', path: '/writer/images', icon: <ImageIcon /> },
|
||||||
|
{ label: 'Review', path: '/writer/review', icon: <CheckCircleIcon /> },
|
||||||
{ label: 'Published', path: '/writer/published', icon: <CheckCircleIcon /> },
|
{ label: 'Published', path: '/writer/published', icon: <CheckCircleIcon /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -240,63 +240,8 @@ export default function Images() {
|
|||||||
|
|
||||||
// Bulk action handler
|
// Bulk action handler
|
||||||
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
|
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
|
||||||
if (action === 'bulk_publish_wordpress') {
|
toast.info(`Bulk action "${action}" for ${ids.length} items`);
|
||||||
// Filter to only publish items that are ready and not already published
|
}, [toast]);
|
||||||
const readyItems = images
|
|
||||||
.filter(item => ids.includes(item.content_id.toString()))
|
|
||||||
.filter(item => item.status === 'published' &&
|
|
||||||
(!item.external_id || !item.external_url) &&
|
|
||||||
(!item.sync_status || item.sync_status !== 'published'));
|
|
||||||
|
|
||||||
if (readyItems.length === 0) {
|
|
||||||
toast.warning('No items are ready for WordPress publishing. Items must be published and not already synced to WordPress.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
let successCount = 0;
|
|
||||||
let failedCount = 0;
|
|
||||||
|
|
||||||
// Publish each item individually using the unified publisher API
|
|
||||||
for (const item of readyItems) {
|
|
||||||
try {
|
|
||||||
const response = await fetchAPI('/v1/publisher/publish/', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
content_id: item.content_id,
|
|
||||||
destinations: ['wordpress']
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
successCount++;
|
|
||||||
} else {
|
|
||||||
failedCount++;
|
|
||||||
console.warn(`Failed to publish content ${item.content_id}:`, response.error);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
failedCount++;
|
|
||||||
console.error(`Error publishing content ${item.content_id}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (successCount > 0) {
|
|
||||||
toast.success(`Successfully published ${successCount} item(s) to WordPress`);
|
|
||||||
}
|
|
||||||
if (failedCount > 0) {
|
|
||||||
toast.warning(`${failedCount} item(s) failed to publish`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reload images to reflect the updated WordPress status
|
|
||||||
loadImages();
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Bulk WordPress publish error:', error);
|
|
||||||
toast.error(`Failed to bulk publish to WordPress: ${error.message || 'Network error'}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
toast.info(`Bulk action "${action}" for ${ids.length} items`);
|
|
||||||
}
|
|
||||||
}, [images, toast, loadImages]);
|
|
||||||
|
|
||||||
// Row action handler
|
// Row action handler
|
||||||
const handleRowAction = useCallback(async (action: string, row: ContentImagesGroup) => {
|
const handleRowAction = useCallback(async (action: string, row: ContentImagesGroup) => {
|
||||||
@@ -304,30 +249,8 @@ export default function Images() {
|
|||||||
setStatusUpdateContentId(row.content_id);
|
setStatusUpdateContentId(row.content_id);
|
||||||
setStatusUpdateRecordName(row.content_title || `Content #${row.content_id}`);
|
setStatusUpdateRecordName(row.content_title || `Content #${row.content_id}`);
|
||||||
setIsStatusModalOpen(true);
|
setIsStatusModalOpen(true);
|
||||||
} else if (action === 'publish_wordpress') {
|
|
||||||
// Handle WordPress publishing for individual item
|
|
||||||
try {
|
|
||||||
const response = await fetchAPI('/v1/publisher/publish/', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({
|
|
||||||
content_id: row.content_id,
|
|
||||||
destinations: ['wordpress']
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
toast.success(`Successfully published "${row.content_title}" to WordPress`);
|
|
||||||
// Reload images to reflect the updated WordPress status
|
|
||||||
loadImages();
|
|
||||||
} else {
|
|
||||||
toast.error(`Failed to publish: ${response.error || response.message}`);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('WordPress publish error:', error);
|
|
||||||
toast.error(`Failed to publish to WordPress: ${error.message || 'Network error'}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [loadImages, toast]);
|
}, []);
|
||||||
|
|
||||||
// Handle status update confirmation
|
// Handle status update confirmation
|
||||||
const handleStatusUpdate = useCallback(async (status: string) => {
|
const handleStatusUpdate = useCallback(async (status: string) => {
|
||||||
@@ -577,6 +500,7 @@ export default function Images() {
|
|||||||
{ label: 'Tasks', path: '/writer/tasks', icon: <TaskIcon /> },
|
{ label: 'Tasks', path: '/writer/tasks', icon: <TaskIcon /> },
|
||||||
{ label: 'Content', path: '/writer/content', icon: <FileIcon /> },
|
{ label: 'Content', path: '/writer/content', icon: <FileIcon /> },
|
||||||
{ label: 'Images', path: '/writer/images', icon: <ImageIcon /> },
|
{ label: 'Images', path: '/writer/images', icon: <ImageIcon /> },
|
||||||
|
{ label: 'Review', path: '/writer/review', icon: <CheckCircleIcon /> },
|
||||||
{ label: 'Published', path: '/writer/published', icon: <CheckCircleIcon /> },
|
{ label: 'Published', path: '/writer/published', icon: <CheckCircleIcon /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ export default function Published() {
|
|||||||
{ label: 'Tasks', path: '/writer/tasks', icon: <TaskIcon /> },
|
{ label: 'Tasks', path: '/writer/tasks', icon: <TaskIcon /> },
|
||||||
{ label: 'Content', path: '/writer/content', icon: <FileIcon /> },
|
{ label: 'Content', path: '/writer/content', icon: <FileIcon /> },
|
||||||
{ label: 'Images', path: '/writer/images', icon: <ImageIcon /> },
|
{ label: 'Images', path: '/writer/images', icon: <ImageIcon /> },
|
||||||
|
{ label: 'Review', path: '/writer/review', icon: <CheckCircleIcon /> },
|
||||||
{ label: 'Published', path: '/writer/published', icon: <CheckCircleIcon /> },
|
{ label: 'Published', path: '/writer/published', icon: <CheckCircleIcon /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
337
frontend/src/pages/Writer/Review.tsx
Normal file
337
frontend/src/pages/Writer/Review.tsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
/**
|
||||||
|
* Review Page - Built with TablePageTemplate
|
||||||
|
* Shows content with status='review' ready for publishing
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
import TablePageTemplate from '../../templates/TablePageTemplate';
|
||||||
|
import {
|
||||||
|
fetchContent,
|
||||||
|
Content,
|
||||||
|
ContentListResponse,
|
||||||
|
ContentFilters,
|
||||||
|
fetchAPI,
|
||||||
|
} from '../../services/api';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||||
|
import { FileIcon, TaskIcon, ImageIcon, CheckCircleIcon } from '../../icons';
|
||||||
|
import { createReviewPageConfig } from '../../config/pages/review.config';
|
||||||
|
import { useSectorStore } from '../../store/sectorStore';
|
||||||
|
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||||
|
import PageHeader from '../../components/common/PageHeader';
|
||||||
|
import ModuleNavigationTabs from '../../components/navigation/ModuleNavigationTabs';
|
||||||
|
import ModuleMetricsFooter from '../../components/dashboard/ModuleMetricsFooter';
|
||||||
|
|
||||||
|
export default function Review() {
|
||||||
|
const toast = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { activeSector } = useSectorStore();
|
||||||
|
const { pageSize } = usePageSizeStore();
|
||||||
|
|
||||||
|
// Data state
|
||||||
|
const [content, setContent] = useState<Content[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Filter state - default to review status
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('review'); // Default to review
|
||||||
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Pagination state
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
// Sorting state
|
||||||
|
const [sortBy, setSortBy] = useState<string>('created_at');
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||||
|
const [showContent, setShowContent] = useState(false);
|
||||||
|
|
||||||
|
// Load content - filtered for review status
|
||||||
|
const loadContent = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setShowContent(false);
|
||||||
|
try {
|
||||||
|
const ordering = sortBy ? `${sortDirection === 'desc' ? '-' : ''}${sortBy}` : '-created_at';
|
||||||
|
|
||||||
|
const filters: ContentFilters = {
|
||||||
|
...(searchTerm && { search: searchTerm }),
|
||||||
|
status: 'review', // Always filter for review status
|
||||||
|
page: currentPage,
|
||||||
|
page_size: pageSize,
|
||||||
|
ordering,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data: ContentListResponse = await fetchContent(filters);
|
||||||
|
setContent(data.results || []);
|
||||||
|
setTotalCount(data.count || 0);
|
||||||
|
setTotalPages(Math.ceil((data.count || 0) / pageSize));
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowContent(true);
|
||||||
|
setLoading(false);
|
||||||
|
}, 100);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error loading content:', error);
|
||||||
|
toast.error(`Failed to load content: ${error.message}`);
|
||||||
|
setShowContent(true);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [currentPage, sortBy, sortDirection, searchTerm, pageSize, toast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadContent();
|
||||||
|
}, [loadContent]);
|
||||||
|
|
||||||
|
// Listen for site and sector changes and refresh data
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSiteChange = () => {
|
||||||
|
loadContent();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('site-changed', handleSiteChange);
|
||||||
|
window.addEventListener('sector-changed', handleSiteChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('site-changed', handleSiteChange);
|
||||||
|
window.removeEventListener('sector-changed', handleSiteChange);
|
||||||
|
};
|
||||||
|
}, [loadContent]);
|
||||||
|
|
||||||
|
// Sorting handler
|
||||||
|
const handleSort = useCallback((column: string) => {
|
||||||
|
if (column === sortBy) {
|
||||||
|
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortBy(column);
|
||||||
|
setSortDirection('asc');
|
||||||
|
}
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [sortBy]);
|
||||||
|
|
||||||
|
// Build page config
|
||||||
|
const pageConfig = useMemo(() =>
|
||||||
|
createReviewPageConfig({
|
||||||
|
activeSector,
|
||||||
|
searchTerm,
|
||||||
|
setSearchTerm,
|
||||||
|
statusFilter,
|
||||||
|
setStatusFilter,
|
||||||
|
setCurrentPage,
|
||||||
|
}),
|
||||||
|
[activeSector, searchTerm, statusFilter]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Header metrics (calculated from loaded data)
|
||||||
|
const headerMetrics = useMemo(() =>
|
||||||
|
pageConfig.headerMetrics.map(metric => ({
|
||||||
|
...metric,
|
||||||
|
value: metric.calculate({ content, totalCount }),
|
||||||
|
})),
|
||||||
|
[pageConfig.headerMetrics, content, totalCount]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Export handler
|
||||||
|
const handleBulkExport = useCallback(async (ids: string[]) => {
|
||||||
|
toast.info(`Exporting ${ids.length} item(s)...`);
|
||||||
|
return { success: true };
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
// Publish to WordPress - single item
|
||||||
|
const handlePublishSingle = useCallback(async (row: Content) => {
|
||||||
|
try {
|
||||||
|
const response = await fetchAPI('/v1/publisher/publish/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
content_id: row.id,
|
||||||
|
destinations: ['wordpress']
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
toast.success(`Successfully published "${row.title}" to WordPress`);
|
||||||
|
loadContent(); // Reload to reflect changes
|
||||||
|
} else {
|
||||||
|
toast.error(`Failed to publish: ${response.error || response.message}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('WordPress publish error:', error);
|
||||||
|
toast.error(`Failed to publish to WordPress: ${error.message || 'Network error'}`);
|
||||||
|
}
|
||||||
|
}, [loadContent, toast]);
|
||||||
|
|
||||||
|
// Publish to WordPress - bulk
|
||||||
|
const handlePublishBulk = useCallback(async (ids: string[]) => {
|
||||||
|
try {
|
||||||
|
let successCount = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
|
||||||
|
// Publish each item individually
|
||||||
|
for (const id of ids) {
|
||||||
|
try {
|
||||||
|
const response = await fetchAPI('/v1/publisher/publish/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
content_id: parseInt(id),
|
||||||
|
destinations: ['wordpress']
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
failedCount++;
|
||||||
|
console.warn(`Failed to publish content ${id}:`, response.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
failedCount++;
|
||||||
|
console.error(`Error publishing content ${id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
toast.success(`Successfully published ${successCount} item(s) to WordPress`);
|
||||||
|
}
|
||||||
|
if (failedCount > 0) {
|
||||||
|
toast.warning(`${failedCount} item(s) failed to publish`);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadContent(); // Reload to reflect changes
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Bulk WordPress publish error:', error);
|
||||||
|
toast.error(`Failed to bulk publish to WordPress: ${error.message || 'Network error'}`);
|
||||||
|
}
|
||||||
|
}, [loadContent, toast]);
|
||||||
|
|
||||||
|
// Bulk action handler
|
||||||
|
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
|
||||||
|
if (action === 'bulk_publish_wordpress') {
|
||||||
|
await handlePublishBulk(ids);
|
||||||
|
} else {
|
||||||
|
toast.info(`Bulk action "${action}" for ${ids.length} items`);
|
||||||
|
}
|
||||||
|
}, [handlePublishBulk, toast]);
|
||||||
|
|
||||||
|
// Row action handler
|
||||||
|
const handleRowAction = useCallback(async (action: string, row: Content) => {
|
||||||
|
if (action === 'publish_wordpress') {
|
||||||
|
await handlePublishSingle(row);
|
||||||
|
} else if (action === 'view') {
|
||||||
|
navigate(`/writer/content/${row.id}`);
|
||||||
|
}
|
||||||
|
}, [handlePublishSingle, navigate]);
|
||||||
|
|
||||||
|
// Delete handler (single)
|
||||||
|
const handleDelete = useCallback(async (id: string) => {
|
||||||
|
try {
|
||||||
|
await fetchAPI(`/v1/writer/content/${id}/`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
toast.success('Content deleted successfully');
|
||||||
|
loadContent();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`Failed to delete content: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [loadContent, toast]);
|
||||||
|
|
||||||
|
// Delete handler (bulk)
|
||||||
|
const handleBulkDelete = useCallback(async (ids: string[]) => {
|
||||||
|
try {
|
||||||
|
// Delete each item individually
|
||||||
|
let successCount = 0;
|
||||||
|
for (const id of ids) {
|
||||||
|
try {
|
||||||
|
await fetchAPI(`/v1/writer/content/${id}/`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
successCount++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to delete content ${id}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success(`Deleted ${successCount} content item(s)`);
|
||||||
|
loadContent();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(`Failed to bulk delete: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}, [loadContent, toast]);
|
||||||
|
|
||||||
|
// Writer navigation tabs
|
||||||
|
const writerTabs = [
|
||||||
|
{ label: 'Tasks', path: '/writer/tasks', icon: <TaskIcon /> },
|
||||||
|
{ label: 'Content', path: '/writer/content', icon: <FileIcon /> },
|
||||||
|
{ label: 'Images', path: '/writer/images', icon: <ImageIcon /> },
|
||||||
|
{ label: 'Review', path: '/writer/review', icon: <CheckCircleIcon /> },
|
||||||
|
{ label: 'Published', path: '/writer/published', icon: <CheckCircleIcon /> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Content Review"
|
||||||
|
badge={{ icon: <CheckCircleIcon />, color: 'blue' }}
|
||||||
|
navigation={<ModuleNavigationTabs tabs={writerTabs} />}
|
||||||
|
/>
|
||||||
|
<TablePageTemplate
|
||||||
|
columns={pageConfig.columns}
|
||||||
|
data={content}
|
||||||
|
loading={loading}
|
||||||
|
showContent={showContent}
|
||||||
|
filters={pageConfig.filters}
|
||||||
|
filterValues={{
|
||||||
|
search: searchTerm,
|
||||||
|
}}
|
||||||
|
onFilterChange={(key, value) => {
|
||||||
|
const stringValue = value === null || value === undefined ? '' : String(value);
|
||||||
|
if (key === 'search') {
|
||||||
|
setSearchTerm(stringValue);
|
||||||
|
}
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
onBulkExport={handleBulkExport}
|
||||||
|
onBulkAction={handleBulkAction}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onBulkDelete={handleBulkDelete}
|
||||||
|
getItemDisplayName={(row: Content) => row.title || `Content #${row.id}`}
|
||||||
|
onExport={async () => {
|
||||||
|
toast.info('Export functionality coming soon');
|
||||||
|
}}
|
||||||
|
selectionLabel="content"
|
||||||
|
pagination={{
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
totalCount,
|
||||||
|
onPageChange: setCurrentPage,
|
||||||
|
}}
|
||||||
|
selection={{
|
||||||
|
selectedIds,
|
||||||
|
onSelectionChange: setSelectedIds,
|
||||||
|
}}
|
||||||
|
sorting={{
|
||||||
|
sortBy,
|
||||||
|
sortDirection,
|
||||||
|
onSort: handleSort,
|
||||||
|
}}
|
||||||
|
headerMetrics={headerMetrics}
|
||||||
|
onFilterReset={() => {
|
||||||
|
setSearchTerm('');
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
onRowAction={handleRowAction}
|
||||||
|
/>
|
||||||
|
<ModuleMetricsFooter
|
||||||
|
metrics={[
|
||||||
|
{
|
||||||
|
title: 'Ready to Publish',
|
||||||
|
value: content.length,
|
||||||
|
subtitle: 'Total review items',
|
||||||
|
icon: <CheckCircleIcon className="w-5 h-5" />,
|
||||||
|
accentColor: 'blue',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2017,11 +2017,17 @@ export interface Content {
|
|||||||
// Relations
|
// Relations
|
||||||
cluster_id: number;
|
cluster_id: number;
|
||||||
cluster_name?: string | null;
|
cluster_name?: string | null;
|
||||||
|
sector_name?: string | null;
|
||||||
taxonomy_terms?: Array<{
|
taxonomy_terms?: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
taxonomy_type: string;
|
taxonomy_type: string;
|
||||||
}>;
|
}>;
|
||||||
|
taxonomy_terms_data?: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
taxonomy_type: string;
|
||||||
|
}>;
|
||||||
// WordPress integration
|
// WordPress integration
|
||||||
external_id?: string | null;
|
external_id?: string | null;
|
||||||
external_url?: string | null;
|
external_url?: string | null;
|
||||||
|
|||||||
@@ -761,6 +761,50 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{content.cluster_name && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Cluster</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{content.cluster_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Categories */}
|
||||||
|
{content.taxonomy_terms_data && content.taxonomy_terms_data.filter(term => term.taxonomy_type === 'category').length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Category</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{content.taxonomy_terms_data
|
||||||
|
.filter(term => term.taxonomy_type === 'category')
|
||||||
|
.map((category) => (
|
||||||
|
<span
|
||||||
|
key={category.id}
|
||||||
|
className="px-3 py-1 bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 rounded-full text-xs font-medium"
|
||||||
|
>
|
||||||
|
{category.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Tags */}
|
||||||
|
{content.taxonomy_terms_data && content.taxonomy_terms_data.filter(term => term.taxonomy_type === 'tag').length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">Tags</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{content.taxonomy_terms_data
|
||||||
|
.filter(term => term.taxonomy_type === 'tag')
|
||||||
|
.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag.id}
|
||||||
|
className="px-3 py-1 bg-brand-50 dark:bg-brand-900/20 text-brand-700 dark:text-brand-300 rounded-full text-xs font-medium"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user