962 lines
35 KiB
TypeScript
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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|