Files
igny8/frontend/src/pages/Writer/Approved.tsx
2026-01-19 15:37:03 +00:00

962 lines
35 KiB
TypeScript

/**
* Approved Page - Built with TablePageTemplate
* Shows approved content ready for publishing to WordPress/external sites
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import TablePageTemplate from '../../templates/TablePageTemplate';
import {
fetchContent,
fetchImages,
fetchWriterContentFilterOptions,
Content,
ContentListResponse,
ContentFilters,
fetchAPI,
deleteContent,
bulkDeleteContent,
} from '../../services/api';
import { useToast } from '../../components/ui/toast/ToastContainer';
import { FileIcon, CheckCircleIcon, BoltIcon } from '../../icons';
import { RocketLaunchIcon } from '@heroicons/react/24/outline';
import { createApprovedPageConfig } from '../../config/pages/approved.config';
import { useSectorStore } from '../../store/sectorStore';
import { useSiteStore } from '../../store/siteStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import PageHeader from '../../components/common/PageHeader';
import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter';
import PublishingProgressModal, { PublishingProgressState } from '../../components/common/PublishingProgressModal';
import BulkPublishingModal, { PublishQueueItem } from '../../components/common/BulkPublishingModal';
import PublishLimitModal from '../../components/common/PublishLimitModal';
import ScheduleContentModal from '../../components/common/ScheduleContentModal';
import BulkScheduleModal from '../../components/common/BulkScheduleModal';
import BulkSchedulePreviewModal from '../../components/common/BulkSchedulePreviewModal';
import ErrorDetailsModal from '../../components/common/ErrorDetailsModal';
export default function Approved() {
const toast = useToast();
const navigate = useNavigate();
const { activeSector, sectors } = useSectorStore();
const { activeSite } = useSiteStore();
const { pageSize } = usePageSizeStore();
// Data state
const [content, setContent] = useState<Content[]>([]);
const [loading, setLoading] = useState(true);
// Total counts for footer widget and header metrics (not page-filtered)
const [totalContent, setTotalContent] = useState(0);
const [totalDraft, setTotalDraft] = useState(0);
const [totalReview, setTotalReview] = useState(0);
const [totalApproved, setTotalApproved] = useState(0);
const [totalPublished, setTotalPublished] = useState(0);
const [totalImagesCount, setTotalImagesCount] = useState(0);
const [generatedImagesCount, setGeneratedImagesCount] = useState(0);
// Dynamic filter options (loaded from backend)
const [statusOptions, setStatusOptions] = useState<Array<{value: string; label: string}> | undefined>(undefined);
const [siteStatusOptions, setSiteStatusOptions] = useState<Array<{value: string; label: string}> | undefined>(undefined);
const [contentTypeOptions, setContentTypeOptions] = useState<Array<{value: string; label: string}> | undefined>(undefined);
const [contentStructureOptions, setContentStructureOptions] = useState<Array<{value: string; label: string}> | undefined>(undefined);
// Filter state
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState(''); // Status filter (draft/review/approved/published)
const [siteStatusFilter, setSiteStatusFilter] = useState(''); // Site status filter (not_published/scheduled/published/failed)
const [contentTypeFilter, setContentTypeFilter] = useState(''); // Content type filter (post/page/product/taxonomy)
const [contentStructureFilter, setContentStructureFilter] = useState(''); // Content structure filter
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);
// Publishing modals state
const [showPublishLimitModal, setShowPublishLimitModal] = useState(false);
const [showSinglePublishModal, setShowSinglePublishModal] = useState(false);
const [showBulkPublishModal, setShowBulkPublishModal] = useState(false);
const [singlePublishState, setSinglePublishState] = useState<PublishingProgressState | null>(null);
const [bulkPublishQueue, setBulkPublishQueue] = useState<PublishQueueItem[]>([]);
// Scheduling modals state
const [showScheduleModal, setShowScheduleModal] = useState(false);
const [showBulkScheduleModal, setShowBulkScheduleModal] = useState(false);
const [showBulkSchedulePreviewModal, setShowBulkSchedulePreviewModal] = useState(false);
const [scheduleContent, setScheduleContent] = useState<Content | null>(null);
const [bulkScheduleItems, setBulkScheduleItems] = useState<Content[]>([]);
const [bulkSchedulePreview, setBulkSchedulePreview] = useState<any>(null);
// Error details modal state
const [showErrorDetailsModal, setShowErrorDetailsModal] = useState(false);
const [errorContent, setErrorContent] = useState<Content | null>(null);
// Load dynamic filter options based on current site's data and applied filters
// This implements cascading filters - each filter's options reflect what's available
// given the other currently applied filters
// APPROVED PAGE: Always constrain to approved+published statuses
const loadFilterOptions = useCallback(async (currentFilters?: {
status?: string;
site_status?: string;
content_type?: string;
content_structure?: string;
search?: string;
}) => {
if (!activeSite) return;
try {
// Always pass status__in to constrain filter options to approved/published content only
const options = await fetchWriterContentFilterOptions(activeSite.id, {
...currentFilters,
status__in: 'approved,published', // Base constraint for this page
});
setStatusOptions(options.statuses || []);
setSiteStatusOptions(options.site_statuses || []);
setContentTypeOptions(options.content_types || []);
setContentStructureOptions(options.content_structures || []);
} catch (error) {
console.error('Error loading filter options:', error);
}
}, [activeSite]);
// Load filter options when site changes (initial load with approved/published constraint)
useEffect(() => {
loadFilterOptions();
}, [activeSite]);
// Reload filter options when any filter changes (cascading filters)
useEffect(() => {
loadFilterOptions({
status: statusFilter || undefined,
site_status: siteStatusFilter || undefined,
content_type: contentTypeFilter || undefined,
content_structure: contentStructureFilter || undefined,
search: searchTerm || undefined,
});
}, [statusFilter, siteStatusFilter, contentTypeFilter, contentStructureFilter, searchTerm, loadFilterOptions]);
// Load total metrics for footer widget and header metrics (not affected by pagination)
const loadTotalMetrics = useCallback(async () => {
try {
// Fetch counts in parallel for performance
const [allRes, draftRes, reviewRes, approvedRes, publishedRes, imagesRes, generatedImagesRes] = await Promise.all([
fetchContent({ page_size: 1, site_id: activeSite?.id }),
fetchContent({ page_size: 1, status: 'draft', site_id: activeSite?.id }),
fetchContent({ page_size: 1, status: 'review', site_id: activeSite?.id }),
fetchContent({ page_size: 1, status: 'approved', site_id: activeSite?.id }),
fetchContent({ page_size: 1, status: 'published', site_id: activeSite?.id }),
fetchImages({ page_size: 1, site_id: activeSite?.id }),
fetchImages({ page_size: 1, site_id: activeSite?.id, status: 'generated' }),
]);
setTotalContent(allRes.count || 0);
setTotalDraft(draftRes.count || 0);
setTotalReview(reviewRes.count || 0);
setTotalApproved(approvedRes.count || 0);
setTotalPublished(publishedRes.count || 0);
setTotalImagesCount(imagesRes.count || 0);
setGeneratedImagesCount(generatedImagesRes.count || 0);
} catch (error) {
console.error('Error loading total metrics:', error);
}
}, [activeSite]);
// Load total metrics on mount
useEffect(() => {
loadTotalMetrics();
}, [loadTotalMetrics]);
// Load content - filtered for approved+published status
const loadContent = useCallback(async () => {
setLoading(true);
setShowContent(false);
try {
const ordering = sortBy ? `${sortDirection === 'desc' ? '-' : ''}${sortBy}` : '-created_at';
const filters: ContentFilters = {
...(searchTerm && { search: searchTerm }),
// Default to approved+published if no status filter selected
...(statusFilter ? { status: statusFilter } : { status__in: 'approved,published' }),
...(siteStatusFilter && { site_status: siteStatusFilter }),
...(contentTypeFilter && { content_type: contentTypeFilter }),
...(contentStructureFilter && { content_structure: contentStructureFilter }),
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, statusFilter, siteStatusFilter, contentTypeFilter, contentStructureFilter, sortBy, sortDirection, searchTerm, pageSize, toast]);
useEffect(() => {
loadContent();
}, [loadContent]);
// Listen for site and sector changes and refresh data
useEffect(() => {
const handleSiteChange = () => {
loadContent();
};
const handleSectorChange = () => {
loadContent();
};
window.addEventListener('siteChanged', handleSiteChange);
window.addEventListener('sectorChanged', handleSectorChange);
return () => {
window.removeEventListener('siteChanged', handleSiteChange);
window.removeEventListener('sectorChanged', handleSectorChange);
};
}, [loadContent]);
// Reset to page 1 when pageSize changes
useEffect(() => {
setCurrentPage(1);
}, [pageSize]);
// Debounced search - reset to page 1 when search term changes
// Only depend on searchTerm to avoid pagination reset on page navigation
useEffect(() => {
const timer = setTimeout(() => {
// Always reset to page 1 when search changes
// The main useEffect will handle reloading when currentPage changes
setCurrentPage(1);
}, 500);
return () => clearTimeout(timer);
}, [searchTerm]);
// Handle sorting
const handleSort = (field: string, direction: 'asc' | 'desc') => {
setSortBy(field || 'created_at');
setSortDirection(direction);
setCurrentPage(1);
};
// Handle single content publish with progress modal
const handleSinglePublish = useCallback(async (row: Content) => {
if (!activeSite) {
toast.error('No active site selected');
return;
}
// Initialize publishing state
const initialState: PublishingProgressState = {
contentId: row.id,
contentTitle: row.title,
destination: activeSite.platform_type || 'wordpress',
siteName: activeSite.name,
status: 'preparing',
progress: 0,
statusMessage: 'Preparing content...',
error: null,
externalUrl: null,
externalId: null,
};
setSinglePublishState(initialState);
setShowSinglePublishModal(true);
try {
// Phase 1: Preparing (0-25%)
await new Promise(resolve => setTimeout(resolve, 500));
setSinglePublishState(prev => prev ? { ...prev, progress: 25, statusMessage: 'Uploading to site...' } : null);
// Phase 2: Uploading (25-50%) - Make API call
const response = await fetchAPI('/v1/publisher/publish/', {
method: 'POST',
body: JSON.stringify({
content_id: row.id,
destinations: [activeSite.platform_type || 'wordpress']
})
});
// Note: fetchAPI unwraps the response, so response IS the data object
// Response format: { success: true, results: [{ success: true, url: '...', external_id: '...' }] }
if (response.success && response.results?.[0]?.success) {
const result = response.results[0];
// Phase 3: Processing (50-75%)
setSinglePublishState(prev => prev ? { ...prev, progress: 50, statusMessage: 'Processing response...' } : null);
await new Promise(resolve => setTimeout(resolve, 300));
// Phase 4: Finalizing (75-100%)
setSinglePublishState(prev => prev ? { ...prev, progress: 75, statusMessage: 'Finalizing...' } : null);
await new Promise(resolve => setTimeout(resolve, 300));
// Complete
setSinglePublishState(prev => prev ? {
...prev,
status: 'completed',
progress: 100,
statusMessage: 'Published successfully!',
externalUrl: result.url,
externalId: result.external_id,
} : null);
loadContent();
} else {
throw new Error(response.error || response.results?.[0]?.error || 'Failed to publish');
}
} catch (error: any) {
console.error('Publish error:', error);
setSinglePublishState(prev => prev ? {
...prev,
status: 'failed',
progress: 0,
statusMessage: 'Failed to publish',
error: error.message || 'Network error',
} : null);
}
}, [activeSite, toast, loadContent]);
// Schedule single content
const handleScheduleContent = useCallback(async (contentId: number, scheduledDate: string) => {
try {
await fetchAPI(`/v1/writer/content/${contentId}/schedule/`, {
method: 'POST',
body: JSON.stringify({ scheduled_publish_at: scheduledDate }),
});
toast.success('Content scheduled successfully');
loadContent();
} catch (error: any) {
toast.error(`Failed to schedule: ${error.message}`);
throw error;
}
}, [toast, loadContent]);
// Reschedule content (same API endpoint)
const handleRescheduleContent = useCallback(async (contentId: number, scheduledDate: string) => {
try {
await fetchAPI(`/v1/writer/content/${contentId}/reschedule/`, {
method: 'POST',
body: JSON.stringify({ scheduled_at: scheduledDate }),
});
toast.success('Content rescheduled successfully');
loadContent();
} catch (error: any) {
toast.error(`Failed to reschedule: ${error.message}`);
throw error;
}
}, [toast, loadContent]);
// Unschedule content
const handleUnscheduleContent = useCallback(async (contentId: number) => {
try {
await fetchAPI(`/v1/writer/content/${contentId}/unschedule/`, {
method: 'POST',
});
toast.success('Content unscheduled successfully');
loadContent();
} catch (error: any) {
toast.error(`Failed to unschedule: ${error.message}`);
}
}, [toast, loadContent]);
// Bulk schedule with manual date/time
const handleBulkScheduleManual = useCallback(async (contentIds: number[], scheduledDate: string) => {
try {
let successCount = 0;
let failedCount = 0;
for (const contentId of contentIds) {
try {
await fetchAPI(`/v1/writer/content/${contentId}/schedule/`, {
method: 'POST',
body: JSON.stringify({ scheduled_publish_at: scheduledDate }),
});
successCount++;
} catch (error) {
console.error(`Failed to schedule content ${contentId}:`, error);
failedCount++;
}
}
if (successCount > 0) {
toast.success(`Scheduled ${successCount} item(s)`);
}
if (failedCount > 0) {
toast.warning(`${failedCount} item(s) failed to schedule`);
}
loadContent();
} catch (error: any) {
toast.error(`Failed to schedule: ${error.message}`);
throw error;
}
}, [toast, loadContent]);
// Bulk schedule with site defaults - show preview first
const handleBulkScheduleWithDefaults = useCallback(async () => {
if (!activeSite || selectedIds.length === 0) return;
try {
const contentIds = selectedIds.map(id => parseInt(id));
// Note: fetchAPI unwraps success_response, so response IS the data object
// Response format: { scheduled_count: N, schedule_preview: [...], site_settings: {...} }
const response = await fetchAPI('/v1/writer/content/bulk_schedule_preview/', {
method: 'POST',
body: JSON.stringify({
content_ids: contentIds,
site_id: activeSite.id
})
});
// fetchAPI throws on error, so if we get here, response is successful
// Response is the unwrapped data object
if (response && response.schedule_preview) {
setBulkSchedulePreview(response);
setShowBulkSchedulePreviewModal(true);
} else {
throw new Error('Invalid response from server');
}
} catch (error: any) {
toast.error(`Failed to generate schedule preview: ${error.message}`);
}
}, [activeSite, selectedIds, toast]);
// Confirm bulk schedule with site defaults
const handleConfirmBulkSchedule = useCallback(async () => {
if (!activeSite || selectedIds.length === 0) return;
try {
const contentIds = selectedIds.map(id => parseInt(id));
// Note: fetchAPI unwraps success_response, so response IS the data object
const response = await fetchAPI('/v1/writer/content/bulk_schedule/', {
method: 'POST',
body: JSON.stringify({
content_ids: contentIds,
use_site_defaults: true,
site_id: activeSite.id
})
});
// fetchAPI throws on error, so if we get here, response is successful
if (response && response.scheduled_count !== undefined) {
toast.success(`Scheduled ${response.scheduled_count} item(s)`);
setShowBulkSchedulePreviewModal(false);
setBulkSchedulePreview(null);
setSelectedIds([]);
loadContent();
} else {
throw new Error('Invalid response from server');
}
} catch (error: any) {
toast.error(`Failed to schedule: ${error.message}`);
}
}, [activeSite, selectedIds, toast, loadContent]);
// Open site settings in new tab
const handleOpenSiteSettings = useCallback(() => {
if (activeSite) {
window.open(`/sites/${activeSite.id}/settings?tab=automation`, '_blank');
}
}, [activeSite]);
// Row action handler
const handleRowAction = useCallback(async (action: string, row: Content) => {
if (action === 'publish_site') {
await handleSinglePublish(row);
} else if (action === 'view_on_site') {
if (row.external_url) {
window.open(row.external_url, '_blank');
} else {
toast.warning('Site URL not available');
}
} else if (action === 'schedule') {
setScheduleContent(row);
setShowScheduleModal(true);
} else if (action === 'reschedule') {
setScheduleContent(row);
setShowScheduleModal(true);
} else if (action === 'unschedule') {
if (window.confirm(`Are you sure you want to unschedule "${row.title}"?`)) {
await handleUnscheduleContent(row.id);
}
} else if (action === 'view_error') {
setErrorContent(row);
setShowErrorDetailsModal(true);
} else if (action === 'edit') {
// Navigate to content editor
if (row.site_id) {
navigate(`/sites/${row.site_id}/posts/${row.id}/edit`);
} else {
// Fallback if site_id not available
toast.warning('Unable to edit: Site information not available');
}
}
}, [toast, navigate, handleSinglePublish, handleUnscheduleContent]);
const handleDelete = useCallback(async (id: number) => {
await deleteContent(id);
loadContent();
}, [loadContent]);
const handleBulkDelete = useCallback(async (ids: number[]) => {
const result = await bulkDeleteContent(ids);
loadContent();
return result;
}, [loadContent]);
// Handle bulk publish with progress modal and limit validation
const handleBulkPublishToSite = useCallback(async (ids: string[]) => {
if (!activeSite) {
toast.error('No active site selected');
return;
}
// Validate: Max 5 items for direct bulk publish
if (ids.length > 5) {
setShowPublishLimitModal(true);
return;
}
try {
// Initialize queue
const queue: PublishQueueItem[] = ids.map((id, index) => {
const contentItem = content.find(c => c.id === parseInt(id));
return {
contentId: parseInt(id),
contentTitle: contentItem?.title || `Content #${id}`,
index: index + 1,
status: 'pending' as const,
progress: 0,
statusMessage: 'Pending',
error: null,
externalUrl: null,
externalId: null,
};
});
setBulkPublishQueue(queue);
setShowBulkPublishModal(true);
// Process sequentially
for (let i = 0; i < queue.length; i++) {
// Update status to processing
setBulkPublishQueue(prev => prev.map((item, idx) =>
idx === i ? { ...item, status: 'processing', progress: 0, statusMessage: 'Preparing...' } : item
));
try {
// Simulate progress animation
await new Promise(resolve => setTimeout(resolve, 300));
setBulkPublishQueue(prev => prev.map((item, idx) =>
idx === i ? { ...item, progress: 25, statusMessage: 'Uploading to site...' } : item
));
// Call API
const response = await fetchAPI('/v1/publisher/publish/', {
method: 'POST',
body: JSON.stringify({
content_id: queue[i].contentId,
destinations: [activeSite.platform_type || 'wordpress']
})
});
// Handle response - fetchAPI unwraps the data object
// Response format: { success: true, results: [{ success: true, url: '...', external_id: '...' }] }
if (response.success && response.results?.[0]?.success) {
const result = response.results[0];
// Animate to completion
setBulkPublishQueue(prev => prev.map((item, idx) =>
idx === i ? { ...item, progress: 75, statusMessage: 'Finalizing...' } : item
));
await new Promise(resolve => setTimeout(resolve, 200));
setBulkPublishQueue(prev => prev.map((item, idx) =>
idx === i ? {
...item,
status: 'completed',
progress: 100,
statusMessage: 'Published',
externalUrl: result.url,
externalId: result.external_id,
} : item
));
} else {
throw new Error(response.error || response.results?.[0]?.error || 'Unknown error');
}
} catch (error: any) {
console.error(`Error publishing content ${queue[i].contentId}:`, error);
setBulkPublishQueue(prev => prev.map((item, idx) =>
idx === i ? {
...item,
status: 'failed',
progress: 0,
statusMessage: 'Failed',
error: error.message || 'Network error',
} : item
));
}
}
// Refresh content after all done
loadContent();
} catch (error: any) {
toast.error(`Failed to bulk publish: ${error.message}`);
throw error;
}
}, [activeSite, content, toast, loadContent]);
// Bulk action handler
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
if (action === 'bulk_publish_site') {
await handleBulkPublishToSite(ids);
} else if (action === 'bulk_schedule_manual') {
// Manual bulk scheduling (same time for all) via modal
const numericIds = ids.map(id => parseInt(id));
const items = content.filter(item => numericIds.includes(item.id));
setBulkScheduleItems(items);
setShowBulkScheduleModal(true);
} else if (action === 'bulk_schedule_defaults') {
// Schedule with site defaults
handleBulkScheduleWithDefaults(ids);
}
}, [handleBulkPublishToSite, handleBulkScheduleWithDefaults, content]);
// Bulk status update handler
const handleBulkUpdateStatus = useCallback(async (ids: string[], status: string) => {
try {
const numIds = ids.map(id => parseInt(id));
// Note: This would need a backend endpoint like /v1/writer/content/bulk_update/
// For now, just show a toast
toast.info('Bulk status update functionality coming soon');
} catch (error: any) {
throw error;
}
}, [toast]);
// Bulk export handler
const handleBulkExport = useCallback(async (ids: string[]) => {
try {
if (!ids || ids.length === 0) {
throw new Error('No records selected for export');
}
toast.info('Export functionality coming soon');
} catch (error: any) {
throw error;
}
}, [toast]);
// Create page config
const pageConfig = useMemo(() => {
return createApprovedPageConfig({
searchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
siteStatusFilter,
setSiteStatusFilter,
setCurrentPage,
activeSector,
sectors,
onRowClick: (row: Content) => {
navigate(`/writer/content/${row.id}`);
},
statusOptions,
siteStatusOptions,
contentTypeOptions,
contentStructureOptions,
});
}, [searchTerm, statusFilter, siteStatusFilter, activeSector, sectors, statusOptions, siteStatusOptions, contentTypeOptions, contentStructureOptions, navigate]);
// Calculate header metrics - use totals from API calls (not page data)
// This ensures metrics show correct totals across all pages, not just current page
const headerMetrics = useMemo(() => {
if (!pageConfig?.headerMetrics) return [];
// Override the calculate function to use pre-loaded totals instead of filtering page data
return pageConfig.headerMetrics.map((metric) => {
let value: number;
switch (metric.label) {
case 'Content':
value = totalContent || 0;
break;
case 'Draft':
value = totalDraft;
break;
case 'In Review':
value = totalReview;
break;
case 'Approved':
value = totalApproved;
break;
case 'Published':
value = totalPublished;
break;
case 'Images':
value = totalImagesCount;
return {
label: metric.label,
displayValue: `${generatedImagesCount}/${totalImagesCount}`,
value,
accentColor: metric.accentColor,
tooltip: (metric as any).tooltip,
};
default:
value = metric.calculate({ content, totalCount });
}
return {
label: metric.label,
value,
accentColor: metric.accentColor,
tooltip: (metric as any).tooltip,
};
});
}, [pageConfig?.headerMetrics, content, totalCount, totalContent, totalDraft, totalReview, totalApproved, totalPublished, totalImagesCount, generatedImagesCount]);
return (
<>
<PageHeader
title="Publish / Schedule"
badge={{ icon: <RocketLaunchIcon />, color: 'green' }}
parent="Publisher"
/>
<TablePageTemplate
columns={pageConfig.columns}
data={content}
loading={loading}
showContent={showContent}
filters={pageConfig.filters}
filterValues={{
search: searchTerm,
status: statusFilter,
site_status: siteStatusFilter,
content_type: contentTypeFilter,
content_structure: contentStructureFilter,
}}
primaryAction={{
label: selectedIds.length > 5 ? 'Publish (Limit Exceeded)' : 'Publish to Site',
icon: <BoltIcon className="w-4 h-4" />,
onClick: () => handleBulkAction('bulk_publish_site', selectedIds),
variant: 'success',
disabled: selectedIds.length === 0,
tooltip: selectedIds.length > 5
? 'You can only publish 5 items at once. Use scheduling for more.'
: selectedIds.length > 0
? `Publish ${selectedIds.length} item${selectedIds.length !== 1 ? 's' : ''} to ${activeSite?.name || 'site'}`
: 'Select items to publish',
}}
onFilterChange={(key: string, value: any) => {
if (key === 'search') {
setSearchTerm(value);
} else if (key === 'status') {
setStatusFilter(value);
setCurrentPage(1);
} else if (key === 'site_status') {
setSiteStatusFilter(value);
setCurrentPage(1);
} else if (key === 'content_type') {
setContentTypeFilter(value);
setCurrentPage(1);
} else if (key === 'content_structure') {
setContentStructureFilter(value);
setCurrentPage(1);
}
}}
onFilterReset={() => {
setSearchTerm('');
setStatusFilter('');
setSiteStatusFilter('');
setContentTypeFilter('');
setContentStructureFilter('');
setCurrentPage(1);
}}
pagination={{
currentPage,
totalPages,
totalCount,
onPageChange: setCurrentPage,
}}
selection={{
selectedIds,
onSelectionChange: setSelectedIds,
}}
sorting={{
sortBy,
sortDirection,
onSort: handleSort,
}}
headerMetrics={headerMetrics}
onRowAction={handleRowAction}
onDelete={handleDelete}
onBulkDelete={handleBulkDelete}
onBulkAction={handleBulkAction}
onBulkUpdateStatus={handleBulkUpdateStatus}
onBulkExport={handleBulkExport}
getItemDisplayName={(row: Content) => row.title || `Content #${row.id}`}
/>
{/* Three Widget Footer - Section 3 Layout */}
<StandardThreeWidgetFooter
submoduleColor="green"
pageProgress={{
title: 'Page Progress',
submoduleColor: 'green',
metrics: [
{ label: 'Published', value: totalCount },
{ label: 'On Site', value: content.filter(c => c.external_id).length, percentage: `${totalCount > 0 ? Math.round((content.filter(c => c.external_id).length / totalCount) * 100) : 0}%` },
{ label: 'Pending', value: content.filter(c => !c.external_id).length },
],
progress: {
value: totalCount > 0 ? Math.round((content.filter(c => c.external_id).length / totalCount) * 100) : 0,
label: 'On Site',
color: 'green',
},
hint: content.filter(c => !c.external_id).length > 0
? `${content.filter(c => !c.external_id).length} article${content.filter(c => !c.external_id).length !== 1 ? 's' : ''} pending sync to site`
: 'All articles synced to site!',
statusInsight: content.filter(c => !c.external_id).length > 0
? `Select articles and publish to your WordPress site.`
: totalCount > 0
? `All content published! Check your site for live articles.`
: `No approved content. Approve articles from Review page.`,
}}
module="writer"
/>
{/* Publishing Modals */}
{singlePublishState && (
<PublishingProgressModal
isOpen={showSinglePublishModal}
onClose={() => setShowSinglePublishModal(false)}
publishingState={singlePublishState}
onRetry={() => {
setShowSinglePublishModal(false);
const contentItem = content.find(c => c.id === singlePublishState.contentId);
if (contentItem) {
handleSinglePublish(contentItem);
}
}}
/>
)}
<BulkPublishingModal
isOpen={showBulkPublishModal}
onClose={() => {
setShowBulkPublishModal(false);
setBulkPublishQueue([]);
setSelectedIds([]);
}}
queue={bulkPublishQueue}
siteName={activeSite?.name || 'Site'}
destination={activeSite?.platform_type || 'wordpress'}
onUpdateQueue={setBulkPublishQueue}
onRetryItem={(contentId) => {
const contentItem = content.find(c => c.id === contentId);
if (contentItem) {
handleSinglePublish(contentItem);
}
}}
/>
<PublishLimitModal
isOpen={showPublishLimitModal}
onClose={() => setShowPublishLimitModal(false)}
selectedCount={selectedIds.length}
onScheduleInstead={() => {
setShowPublishLimitModal(false);
handleBulkScheduleWithDefaults();
}}
/>
<ScheduleContentModal
isOpen={showScheduleModal}
onClose={() => {
setShowScheduleModal(false);
setScheduleContent(null);
}}
content={scheduleContent}
onSchedule={async (contentId, scheduledDate) => {
if (scheduleContent?.site_status === 'scheduled' || scheduleContent?.site_status === 'failed') {
await handleRescheduleContent(contentId, scheduledDate);
} else {
await handleScheduleContent(contentId, scheduledDate);
}
setShowScheduleModal(false);
setScheduleContent(null);
}}
mode={scheduleContent?.site_status === 'scheduled' || scheduleContent?.site_status === 'failed' ? 'reschedule' : 'schedule'}
/>
<BulkScheduleModal
isOpen={showBulkScheduleModal}
onClose={() => {
setShowBulkScheduleModal(false);
setBulkScheduleItems([]);
}}
contentItems={bulkScheduleItems}
onSchedule={async (contentIds, scheduledDate) => {
await handleBulkScheduleManual(contentIds, scheduledDate);
setShowBulkScheduleModal(false);
setBulkScheduleItems([]);
setSelectedIds([]);
}}
/>
<BulkSchedulePreviewModal
isOpen={showBulkSchedulePreviewModal}
onClose={() => {
setShowBulkSchedulePreviewModal(false);
setBulkSchedulePreview(null);
}}
previewData={bulkSchedulePreview}
onConfirm={handleConfirmBulkSchedule}
onChangeSettings={handleOpenSiteSettings}
siteId={activeSite?.id || 0}
/>
<ErrorDetailsModal
isOpen={showErrorDetailsModal}
onClose={() => {
setShowErrorDetailsModal(false);
setErrorContent(null);
}}
content={errorContent}
site={activeSite}
onPublishNow={() => {
if (errorContent) {
handleSinglePublish(errorContent);
}
}}
onReschedule={() => {
if (errorContent) {
setScheduleContent(errorContent);
setShowScheduleModal(true);
}
}}
onFixSettings={handleOpenSiteSettings}
/>
</>
);
}