From 08394554182516cb9139498d6ba620997c0fadb4 Mon Sep 17 00:00:00 2001
From: "IGNY8 VPS (Salman)"
Date: Fri, 28 Nov 2025 12:25:45 +0000
Subject: [PATCH] fine tuning
---
.../ai/functions/generate_content.py | 53 ++-
backend/igny8_core/ai/tasks.py | 19 +
backend/igny8_core/business/content/models.py | 1 +
.../0010_add_review_status_to_content.py | 27 ++
.../igny8_core/modules/writer/serializers.py | 35 ++
frontend/src/App.tsx | 8 +
frontend/src/config/pages/review.config.tsx | 224 ++++++++++++
.../src/config/pages/table-actions.config.tsx | 26 +-
frontend/src/pages/Settings/Publishing.tsx | 69 ++++
frontend/src/pages/Writer/Content.tsx | 1 +
frontend/src/pages/Writer/Images.tsx | 84 +----
frontend/src/pages/Writer/Published.tsx | 1 +
frontend/src/pages/Writer/Review.tsx | 337 ++++++++++++++++++
frontend/src/services/api.ts | 6 +
.../src/templates/ContentViewTemplate.tsx | 44 +++
15 files changed, 840 insertions(+), 95 deletions(-)
create mode 100644 backend/igny8_core/modules/writer/migrations/0010_add_review_status_to_content.py
create mode 100644 frontend/src/config/pages/review.config.tsx
create mode 100644 frontend/src/pages/Writer/Review.tsx
diff --git a/backend/igny8_core/ai/functions/generate_content.py b/backend/igny8_core/ai/functions/generate_content.py
index 6afc57d2..e3bb3b57 100644
--- a/backend/igny8_core/ai/functions/generate_content.py
+++ b/backend/igny8_core/ai/functions/generate_content.py
@@ -161,6 +161,7 @@ class GenerateContentFunction(BaseAIFunction):
"""
STAGE 3: Save content using final Stage 1 Content model schema.
Creates independent Content record (no OneToOne to Task).
+ Handles tags and categories from AI response.
"""
if isinstance(original_data, list):
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')
primary_keyword = parsed.get('primary_keyword') or parsed.get('focus_keyword')
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:
# Plain text response
content_html = str(parsed)
@@ -187,6 +191,8 @@ class GenerateContentFunction(BaseAIFunction):
meta_description = None
primary_keyword = None
secondary_keywords = []
+ tags_from_response = []
+ categories_from_response = []
# Calculate word count
word_count = 0
@@ -222,8 +228,51 @@ class GenerateContentFunction(BaseAIFunction):
if task.taxonomy_term:
content_record.taxonomy_terms.add(task.taxonomy_term)
- # Link all keywords from task as taxonomy terms (if they have taxonomy mappings)
- # This is optional - keywords are M2M on Task, not directly on Content
+ # Process tags from AI response
+ 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
task.status = 'completed'
diff --git a/backend/igny8_core/ai/tasks.py b/backend/igny8_core/ai/tasks.py
index 40f1000b..a770f6a9 100644
--- a/backend/igny8_core/ai/tasks.py
+++ b/backend/igny8_core/ai/tasks.py
@@ -707,6 +707,25 @@ def process_image_generation_queue(self, image_ids: list, account_id: int = None
})
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
logger.info("=" * 80)
logger.info(f"process_image_generation_queue COMPLETED")
diff --git a/backend/igny8_core/business/content/models.py b/backend/igny8_core/business/content/models.py
index d6fddf8e..4e4167b9 100644
--- a/backend/igny8_core/business/content/models.py
+++ b/backend/igny8_core/business/content/models.py
@@ -235,6 +235,7 @@ class Content(SiteSectorBaseModel):
# Status tracking
STATUS_CHOICES = [
('draft', 'Draft'),
+ ('review', 'Review'),
('published', 'Published'),
]
status = models.CharField(
diff --git a/backend/igny8_core/modules/writer/migrations/0010_add_review_status_to_content.py b/backend/igny8_core/modules/writer/migrations/0010_add_review_status_to_content.py
new file mode 100644
index 00000000..0f4e051f
--- /dev/null
+++ b/backend/igny8_core/modules/writer/migrations/0010_add_review_status_to_content.py
@@ -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
+ ),
+ ),
+ ]
diff --git a/backend/igny8_core/modules/writer/serializers.py b/backend/igny8_core/modules/writer/serializers.py
index 37e033e7..af14bdfc 100644
--- a/backend/igny8_core/modules/writer/serializers.py
+++ b/backend/igny8_core/modules/writer/serializers.py
@@ -154,6 +154,9 @@ class ContentSerializer(serializers.ModelSerializer):
cluster_name = serializers.SerializerMethodField()
sector_name = 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)
sector_id = serializers.IntegerField(write_only=True, required=False)
@@ -181,6 +184,9 @@ class ContentSerializer(serializers.ModelSerializer):
'site_id',
'sector_id',
'account_id',
+ 'has_image_prompts',
+ 'image_status',
+ 'has_generated_images',
'created_at',
'updated_at',
]
@@ -239,6 +245,35 @@ class ContentSerializer(serializers.ModelSerializer):
}
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):
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index adb98d3e..3982f674 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -32,6 +32,7 @@ const Content = lazy(() => import("./pages/Writer/Content"));
const ContentView = lazy(() => import("./pages/Writer/ContentView"));
const Drafts = lazy(() => import("./pages/Writer/Drafts"));
const Images = lazy(() => import("./pages/Writer/Images"));
+const Review = lazy(() => import("./pages/Writer/Review"));
const Published = lazy(() => import("./pages/Writer/Published"));
// Linker Module - Lazy loaded
@@ -242,6 +243,13 @@ export default function App() {
} />
+
+
+
+
+
+ } />
diff --git a/frontend/src/config/pages/review.config.tsx b/frontend/src/config/pages/review.config.tsx
new file mode 100644
index 00000000..658d9837
--- /dev/null
+++ b/frontend/src/config/pages/review.config.tsx
@@ -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 - ;
+ return (
+
+ {categories.map((cat: any) => (
+ {cat.name}
+ ))}
+
+ );
+ },
+ 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 - ;
+ return (
+
+ {tags.map((tag: any) => (
+ {tag.name}
+ ))}
+
+ );
+ },
+ 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) => (
+
+
+ {value || `Content #${row.id}`}
+
+
+ ),
+ },
+ {
+ key: 'content_type',
+ label: 'Type',
+ sortable: true,
+ sortField: 'content_type',
+ width: '120px',
+ render: (value: string) => (
+
+ {TYPE_LABELS[value] || value || '-'}
+
+ ),
+ },
+ {
+ key: 'content_structure',
+ label: 'Structure',
+ sortable: true,
+ sortField: 'content_structure',
+ width: '150px',
+ render: (value: string) => (
+
+ {STRUCTURE_LABELS[value] || value || '-'}
+
+ ),
+ },
+ {
+ key: 'cluster_name',
+ label: 'Cluster',
+ sortable: false,
+ width: '150px',
+ render: (_value: any, row: Content) => {
+ const clusterName = row.cluster_name;
+ if (!clusterName) {
+ return - ;
+ }
+ return (
+
+ {clusterName}
+
+ );
+ },
+ },
+ {
+ key: 'word_count',
+ label: 'Words',
+ sortable: true,
+ sortField: 'word_count',
+ numeric: true,
+ width: '100px',
+ render: (value: number) => (
+
+ {value?.toLocaleString() || 0}
+
+ ),
+ },
+ {
+ key: 'created_at',
+ label: 'Created',
+ sortable: true,
+ sortField: 'created_at',
+ date: true,
+ align: 'right',
+ render: (value: string) => (
+
+ {formatRelativeDate(value)}
+
+ ),
+ },
+ ];
+
+ if (showSectorColumn) {
+ columns.splice(4, 0, {
+ key: 'sector_name',
+ label: 'Sector',
+ sortable: false,
+ width: '120px',
+ render: (value: string, row: Content) => (
+
+ {row.sector_name || '-'}
+
+ ),
+ });
+ }
+
+ 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,
+ },
+ ],
+ };
+}
diff --git a/frontend/src/config/pages/table-actions.config.tsx b/frontend/src/config/pages/table-actions.config.tsx
index dce54cd2..c53c537a 100644
--- a/frontend/src/config/pages/table-actions.config.tsx
+++ b/frontend/src/config/pages/table-actions.config.tsx
@@ -341,18 +341,6 @@ const tableActionsConfigs: Record = {
},
'/writer/images': {
rowActions: [
- {
- key: 'publish_wordpress',
- label: 'Publish to WordPress',
- icon: ,
- 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',
label: 'Update Status',
@@ -360,10 +348,21 @@ const tableActionsConfigs: Record = {
variant: 'primary',
},
],
+ bulkActions: [],
+ },
+ '/writer/review': {
+ rowActions: [
+ {
+ key: 'publish_wordpress',
+ label: 'Publish to WordPress',
+ icon: ,
+ variant: 'success',
+ },
+ ],
bulkActions: [
{
key: 'bulk_publish_wordpress',
- label: 'Publish Ready to WordPress',
+ label: 'Publish to WordPress',
icon: ,
variant: 'success',
},
@@ -376,3 +375,4 @@ const tableActionsConfigs: Record = {
},
};
+
diff --git a/frontend/src/pages/Settings/Publishing.tsx b/frontend/src/pages/Settings/Publishing.tsx
index 2f89743a..6952ceb8 100644
--- a/frontend/src/pages/Settings/Publishing.tsx
+++ b/frontend/src/pages/Settings/Publishing.tsx
@@ -8,6 +8,8 @@ import { Card } from '../../components/ui/card';
import Button from '../../components/ui/button/Button';
import Checkbox from '../../components/form/input/Checkbox';
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 { useToast } from '../../components/ui/toast/ToastContainer';
import { fetchAPI } from '../../services/api';
@@ -18,6 +20,9 @@ export default function Publishing() {
const [saving, setSaving] = useState(false);
const [defaultDestinations, setDefaultDestinations] = useState(['sites']);
const [autoPublishEnabled, setAutoPublishEnabled] = useState(false);
+ const [autoSyncEnabled, setAutoSyncEnabled] = useState(false);
+ const [syncInterval, setSyncInterval] = useState(60); // Default 60 minutes
+ const [syncIntervalUnit, setSyncIntervalUnit] = useState<'minutes' | 'hours'>('minutes');
const [publishingRules, setPublishingRules] = useState([]);
useEffect(() => {
@@ -31,6 +36,9 @@ export default function Publishing() {
// For now, use defaults
setDefaultDestinations(['sites']);
setAutoPublishEnabled(false);
+ setAutoSyncEnabled(false);
+ setSyncInterval(60);
+ setSyncIntervalUnit('minutes');
setPublishingRules([]);
} catch (error: any) {
toast.error(`Failed to load settings: ${error.message}`);
@@ -147,6 +155,67 @@ export default function Publishing() {
+ {/* Auto-Sync Settings */}
+
+
+
+
+ Auto-Sync Settings
+
+
+ Configure automatic content synchronization with publishing platforms
+
+
+
+
+ setAutoSyncEnabled(e.target.checked)}
+ label="Enable auto-sync"
+ />
+
+
+ {autoSyncEnabled && (
+
+
+
Sync Interval
+
+
+ setSyncInterval(parseInt(e.target.value) || 1)}
+ placeholder="60"
+ />
+
+
+ setSyncIntervalUnit(e.target.value as 'minutes' | 'hours')}
+ >
+ Minutes
+ Hours
+
+
+
+
+ How often the system should check for and publish ready content
+
+
+
+
+
+ Note: Content will be automatically published every{' '}
+ {syncInterval} {syncIntervalUnit} if it has status "review" and all images are generated.
+
+
+
+ )}
+
+
+
{/* Publishing Rules */}
diff --git a/frontend/src/pages/Writer/Content.tsx b/frontend/src/pages/Writer/Content.tsx
index a0c51878..dda9f989 100644
--- a/frontend/src/pages/Writer/Content.tsx
+++ b/frontend/src/pages/Writer/Content.tsx
@@ -221,6 +221,7 @@ export default function Content() {
{ label: 'Tasks', path: '/writer/tasks', icon: },
{ label: 'Content', path: '/writer/content', icon: },
{ label: 'Images', path: '/writer/images', icon: },
+ { label: 'Review', path: '/writer/review', icon: },
{ label: 'Published', path: '/writer/published', icon: },
];
diff --git a/frontend/src/pages/Writer/Images.tsx b/frontend/src/pages/Writer/Images.tsx
index 0c8c342e..b6978eb2 100644
--- a/frontend/src/pages/Writer/Images.tsx
+++ b/frontend/src/pages/Writer/Images.tsx
@@ -240,63 +240,8 @@ export default function Images() {
// Bulk action handler
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
- if (action === 'bulk_publish_wordpress') {
- // Filter to only publish items that are ready and not already published
- 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]);
+ toast.info(`Bulk action "${action}" for ${ids.length} items`);
+ }, [toast]);
// Row action handler
const handleRowAction = useCallback(async (action: string, row: ContentImagesGroup) => {
@@ -304,30 +249,8 @@ export default function Images() {
setStatusUpdateContentId(row.content_id);
setStatusUpdateRecordName(row.content_title || `Content #${row.content_id}`);
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
const handleStatusUpdate = useCallback(async (status: string) => {
@@ -577,6 +500,7 @@ export default function Images() {
{ label: 'Tasks', path: '/writer/tasks', icon: },
{ label: 'Content', path: '/writer/content', icon: },
{ label: 'Images', path: '/writer/images', icon: },
+ { label: 'Review', path: '/writer/review', icon: },
{ label: 'Published', path: '/writer/published', icon: },
];
diff --git a/frontend/src/pages/Writer/Published.tsx b/frontend/src/pages/Writer/Published.tsx
index c9b07f94..f79b7f00 100644
--- a/frontend/src/pages/Writer/Published.tsx
+++ b/frontend/src/pages/Writer/Published.tsx
@@ -274,6 +274,7 @@ export default function Published() {
{ label: 'Tasks', path: '/writer/tasks', icon: },
{ label: 'Content', path: '/writer/content', icon: },
{ label: 'Images', path: '/writer/images', icon: },
+ { label: 'Review', path: '/writer/review', icon: },
{ label: 'Published', path: '/writer/published', icon: },
];
diff --git a/frontend/src/pages/Writer/Review.tsx b/frontend/src/pages/Writer/Review.tsx
new file mode 100644
index 00000000..ed5dbb38
--- /dev/null
+++ b/frontend/src/pages/Writer/Review.tsx
@@ -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([]);
+ 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([]);
+
+ // Pagination state
+ const [currentPage, setCurrentPage] = useState(1);
+ const [totalPages, setTotalPages] = useState(1);
+ const [totalCount, setTotalCount] = useState(0);
+
+ // Sorting state
+ const [sortBy, setSortBy] = useState('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: },
+ { label: 'Content', path: '/writer/content', icon: },
+ { label: 'Images', path: '/writer/images', icon: },
+ { label: 'Review', path: '/writer/review', icon: },
+ { label: 'Published', path: '/writer/published', icon: },
+ ];
+
+ return (
+ <>
+ , color: 'blue' }}
+ navigation={ }
+ />
+ {
+ 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}
+ />
+ ,
+ accentColor: 'blue',
+ },
+ ]}
+ />
+ >
+ );
+}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index d6ce4912..a24de4a1 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -2017,11 +2017,17 @@ export interface Content {
// Relations
cluster_id: number;
cluster_name?: string | null;
+ sector_name?: string | null;
taxonomy_terms?: Array<{
id: number;
name: string;
taxonomy_type: string;
}>;
+ taxonomy_terms_data?: Array<{
+ id: number;
+ name: string;
+ taxonomy_type: string;
+ }>;
// WordPress integration
external_id?: string | null;
external_url?: string | null;
diff --git a/frontend/src/templates/ContentViewTemplate.tsx b/frontend/src/templates/ContentViewTemplate.tsx
index 5838f87f..a3168ff7 100644
--- a/frontend/src/templates/ContentViewTemplate.tsx
+++ b/frontend/src/templates/ContentViewTemplate.tsx
@@ -761,6 +761,50 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
)}
+ {content.cluster_name && (
+
+
Cluster
+
+ {content.cluster_name}
+
+
+ )}
+ {/* Categories */}
+ {content.taxonomy_terms_data && content.taxonomy_terms_data.filter(term => term.taxonomy_type === 'category').length > 0 && (
+
+
Category
+
+ {content.taxonomy_terms_data
+ .filter(term => term.taxonomy_type === 'category')
+ .map((category) => (
+
+ {category.name}
+
+ ))}
+
+
+ )}
+ {/* Tags */}
+ {content.taxonomy_terms_data && content.taxonomy_terms_data.filter(term => term.taxonomy_type === 'tag').length > 0 && (
+
+
Tags
+
+ {content.taxonomy_terms_data
+ .filter(term => term.taxonomy_type === 'tag')
+ .map((tag) => (
+
+ {tag.name}
+
+ ))}
+
+
+ )}