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 && ( +
+
+ +
+
+ setSyncInterval(parseInt(e.target.value) || 1)} + placeholder="60" + /> +
+
+ +
+
+

+ 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} + + ))} +
+
+ )}