Phase 1-3 Implemented - PUBLISHING-PROGRESS-AND-SCHEDULING-UX-PLAN
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user