Phase 1-3 Implemented - PUBLISHING-PROGRESS-AND-SCHEDULING-UX-PLAN

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-16 14:14:17 +00:00
parent e704ed8bcf
commit 1f0a31fe79
14 changed files with 1809 additions and 224 deletions

View File

@@ -26,6 +26,12 @@ 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';
export default function Approved() {
const toast = useToast();
@@ -70,6 +76,21 @@ export default function Approved() {
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);
// 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
@@ -226,33 +247,240 @@ export default function Approved() {
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']
})
});
if (response.success && response.data?.results?.[0]?.success) {
const result = response.data.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 || '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));
const response = await fetchAPI('/v1/writer/content/bulk_schedule_preview/', {
method: 'POST',
body: JSON.stringify({
content_ids: contentIds,
site_id: activeSite.id
})
});
if (response.success) {
setBulkSchedulePreview(response.data);
setShowBulkSchedulePreviewModal(true);
} else {
throw new Error(response.error || 'Failed to generate preview');
}
} 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));
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
})
});
if (response.success) {
toast.success(`Scheduled ${response.data.scheduled_count} item(s)`);
setShowBulkSchedulePreviewModal(false);
setBulkSchedulePreview(null);
setSelectedIds([]);
loadContent();
} else {
throw new Error(response.error || 'Failed to schedule');
}
} 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=publishing`, '_blank');
}
}, [activeSite]);
// Row action handler
const handleRowAction = useCallback(async (action: string, row: Content) => {
if (action === 'publish_wordpress') {
try {
const response = await fetchAPI('/v1/publisher/publish/', {
method: 'POST',
body: JSON.stringify({
content_id: row.id,
destinations: ['wordpress']
})
});
if (response.success) {
toast.success(`Published "${row.title}" to WordPress`);
loadContent();
} else {
toast.error(response.error || 'Failed to publish');
}
} catch (error: any) {
console.error('WordPress publish error:', error);
toast.error(`Failed to publish: ${error.message || 'Network error'}`);
}
} else if (action === 'view_on_wordpress') {
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('WordPress URL not available');
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 === 'edit') {
// Navigate to content editor
@@ -263,7 +491,7 @@ export default function Approved() {
toast.warning('Unable to edit: Site information not available');
}
}
}, [toast, loadContent, navigate]);
}, [toast, navigate, handleSinglePublish, handleUnscheduleContent]);
const handleDelete = useCallback(async (id: number) => {
await deleteContent(id);
@@ -276,56 +504,113 @@ export default function Approved() {
return result;
}, [loadContent]);
// Bulk WordPress publish
const handleBulkPublishWordPress = useCallback(async (ids: string[]) => {
// 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 {
const contentIds = ids.map(id => parseInt(id));
let successCount = 0;
let failedCount = 0;
// Publish each item individually
for (const contentId of contentIds) {
// 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: contentId,
destinations: ['wordpress']
content_id: queue[i].contentId,
destinations: [activeSite.platform_type || 'wordpress']
})
});
if (response.success) {
successCount++;
// Handle response
if (response.success && response.data?.results?.[0]?.success) {
const result = response.data.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 {
failedCount++;
console.warn(`Failed to publish content ${contentId}:`, response.error);
throw new Error(response.error || 'Unknown error');
}
} catch (error) {
failedCount++;
console.error(`Error publishing content ${contentId}:`, 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
));
}
}
if (successCount > 0) {
toast.success(`Published ${successCount} item(s) to WordPress`);
}
if (failedCount > 0) {
toast.warning(`${failedCount} item(s) failed to publish`);
}
// Refresh content after all done
loadContent();
} catch (error: any) {
toast.error(`Failed to bulk publish: ${error.message}`);
throw error;
}
}, [toast, loadContent]);
}, [activeSite, content, toast, loadContent]);
// Bulk action handler
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
if (action === 'bulk_publish_wordpress') {
await handleBulkPublishWordPress(ids);
if (action === 'bulk_publish_site') {
await handleBulkPublishToSite(ids);
}
}, [handleBulkPublishWordPress]);
}, [handleBulkPublishToSite]);
// Bulk status update handler
const handleBulkUpdateStatus = useCallback(async (ids: string[], status: string) => {
@@ -430,10 +715,16 @@ export default function Approved() {
content_structure: contentStructureFilter,
}}
primaryAction={{
label: 'Publish to Site',
label: selectedIds.length > 5 ? 'Publish (Limit Exceeded)' : 'Publish to Site',
icon: <BoltIcon className="w-4 h-4" />,
onClick: () => handleBulkAction('bulk_publish_wordpress', selectedIds),
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') {
@@ -504,6 +795,97 @@ export default function Approved() {
}}
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}
/>
</>
);
}

View File

@@ -1,6 +1,7 @@
/**
* Review Page - Built with TablePageTemplate
* Shows content with status='review' ready for publishing
* Shows content with status='review' ready for approval
* Content must be approved before publishing from Approved page
*/
import { useState, useEffect, useMemo, useCallback } from 'react';
@@ -284,105 +285,6 @@ export default function Review() {
return { success: true };
}, [toast]);
// Publish to WordPress - single item
const handlePublishSingle = useCallback(async (row: Content) => {
console.group('🚀 [Review.handlePublishSingle] WordPress Publish Started');
console.log('📄 Content Details:', {
id: row.id,
title: row.title,
status: row.status,
timestamp: new Date().toISOString()
});
try {
// Log the payload being sent
const payload = {
content_id: row.id,
destinations: ['wordpress']
};
console.log('📦 Request Payload:', payload);
console.log('🌐 API Endpoint: POST /v1/publisher/publish/');
console.log('📡 Sending request to backend...');
const response = await fetchAPI('/v1/publisher/publish/', {
method: 'POST',
body: JSON.stringify(payload)
});
console.log('📬 Full API Response:', JSON.parse(JSON.stringify(response)));
console.log('📊 Response Structure:', {
success: response.success,
has_data: !!response.data,
has_results: !!response.data?.results,
results_count: response.data?.results?.length || 0,
has_error: !!response.error,
has_message: !!response.message
});
// Handle the response with results array
// Note: Backend wraps result in 'data' key via success_response()
const result = response.data || response; // Fallback to response if no data wrapper
if (result.success && result.results) {
console.log('✅ Overall publish success: true');
console.log('📋 Publish Results:', result.results);
// Check individual destination results
const wordpressResult = result.results.find((r: any) => r.destination === 'wordpress');
console.log('🎯 WordPress Result:', wordpressResult);
if (wordpressResult && wordpressResult.success) {
console.log('✅ WordPress publish successful:', {
external_id: wordpressResult.external_id,
url: wordpressResult.url,
publishing_record_id: wordpressResult.publishing_record_id
});
toast.success(`Published "${row.title}" to WordPress`);
// Update content status to published in UI
loadContent();
} else {
const error = wordpressResult?.error || wordpressResult?.message || 'Publishing failed';
console.error('❌ WordPress publish failed:', {
error: error,
result: wordpressResult
});
toast.error(`Failed to publish: ${error}`);
}
} else if (!result.success) {
// Handle overall failure
console.error('❌ Publish failed (overall):', {
error: result.error,
message: result.message,
results: result.results
});
// Try to extract error from results
let errorMsg = result.error || result.message || 'Publishing failed';
if (result.results && result.results.length > 0) {
const failedResult = result.results[0];
errorMsg = failedResult.error || failedResult.message || errorMsg;
}
toast.error(`Failed to publish: ${errorMsg}`);
} else {
console.warn('⚠️ Unexpected response format:', response);
toast.error('Failed to publish: Unexpected response format');
}
} catch (error: any) {
console.error('❌ Exception during publish:', {
content_id: row.id,
error_type: error.constructor.name,
error_message: error.message,
error_stack: error.stack,
error_object: error
});
toast.error(`Failed to publish to WordPress: ${error.message || 'Network error'}`);
} finally {
console.groupEnd();
}
}, [loadContent, toast]);
// Approve content - single item (changes status from 'review' to 'approved')
const handleApproveSingle = useCallback(async (row: Content) => {
try {
@@ -429,73 +331,23 @@ export default function Review() {
}
}, [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']
})
});
// Backend wraps result in 'data' key via success_response()
const result = response.data || response;
if (result.success) {
successCount++;
} else {
failedCount++;
console.warn(`Failed to publish content ${id}:`, result.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_approve') {
await handleApproveBulk(ids);
} else if (action === 'bulk_publish_wordpress') {
await handlePublishBulk(ids);
} else {
toast.info(`Bulk action "${action}" for ${ids.length} items`);
}
}, [handleApproveBulk, handlePublishBulk, toast]);
}, [handleApproveBulk, toast]);
// Row action handler
const handleRowAction = useCallback(async (action: string, row: Content) => {
if (action === 'approve') {
await handleApproveSingle(row);
} else if (action === 'publish_wordpress') {
await handlePublishSingle(row);
} else if (action === 'view') {
navigate(`/writer/content/${row.id}`);
}
}, [handleApproveSingle, handlePublishSingle, navigate]);
}, [handleApproveSingle, navigate]);
// Delete handler (single)
const handleDelete = useCallback(async (id: string) => {
@@ -611,10 +463,10 @@ export default function Review() {
color: 'amber',
},
hint: totalCount > 0
? `${totalCount} article${totalCount !== 1 ? 's' : ''} ready for review and publishing`
? `${totalCount} article${totalCount !== 1 ? 's' : ''} ready for approval`
: 'No content pending review',
statusInsight: totalCount > 0
? `Review content, edit if needed, then approve for publishing.`
? `Review and approve content. Approved content can be published from the Approved page.`
: `No content in review. Submit drafts from Content page.`,
}}
module="writer"