fine tuning

This commit is contained in:
IGNY8 VPS (Salman)
2025-11-28 12:25:45 +00:00
parent 831b179c49
commit 0839455418
15 changed files with 840 additions and 95 deletions

View File

@@ -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'

View File

@@ -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")

View File

@@ -235,6 +235,7 @@ class Content(SiteSectorBaseModel):
# Status tracking
STATUS_CHOICES = [
('draft', 'Draft'),
('review', 'Review'),
('published', 'Published'),
]
status = models.CharField(

View File

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

View File

@@ -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',
]
@@ -240,6 +246,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):
"""Serializer for ContentTaxonomy model - Stage 1 refactored"""

View File

@@ -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() {
</ModuleGuard>
</Suspense>
} />
<Route path="/writer/review" element={
<Suspense fallback={null}>
<ModuleGuard module="writer">
<Review />
</ModuleGuard>
</Suspense>
} />
<Route path="/writer/published" element={
<Suspense fallback={null}>
<ModuleGuard module="writer">

View 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,
},
],
};
}

View File

@@ -341,18 +341,6 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
},
'/writer/images': {
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',
label: 'Update Status',
@@ -360,10 +348,21 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
variant: 'primary',
},
],
bulkActions: [],
},
'/writer/review': {
rowActions: [
{
key: 'publish_wordpress',
label: 'Publish to WordPress',
icon: <ArrowRightIcon className="w-5 h-5" />,
variant: 'success',
},
],
bulkActions: [
{
key: 'bulk_publish_wordpress',
label: 'Publish Ready to WordPress',
label: 'Publish to WordPress',
icon: <ArrowRightIcon className="w-5 h-5" />,
variant: 'success',
},
@@ -376,3 +375,4 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
},
};

View File

@@ -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<string[]>(['sites']);
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[]>([]);
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() {
</div>
</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 */}
<Card className="p-6">
<PublishingRules rules={publishingRules} onChange={setPublishingRules} />

View File

@@ -221,6 +221,7 @@ export default function Content() {
{ 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 /> },
];

View File

@@ -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: <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 /> },
];

View File

@@ -274,6 +274,7 @@ export default function Published() {
{ 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 /> },
];

View 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',
},
]}
/>
</>
);
}

View File

@@ -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;

View File

@@ -761,6 +761,50 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
</p>
</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>