diff --git a/backend/igny8_core/modules/writer/views.py b/backend/igny8_core/modules/writer/views.py index c04aee8d..92128980 100644 --- a/backend/igny8_core/modules/writer/views.py +++ b/backend/igny8_core/modules/writer/views.py @@ -1589,6 +1589,207 @@ class ContentViewSet(SiteSectorModelViewSet): request=request ) + @action(detail=False, methods=['post'], url_path='bulk_schedule_preview', url_name='bulk_schedule_preview', permission_classes=[IsAuthenticatedAndActive, IsEditorOrAbove]) + def bulk_schedule_preview(self, request): + """ + Preview bulk scheduling with site default settings. + Shows what the schedule would look like before confirming. + + POST /api/v1/writer/content/bulk_schedule_preview/ + { + "content_ids": [123, 124, 125], + "site_id": 45 + } + """ + from django.utils import timezone + from datetime import timedelta + from igny8_core.business.integration.models import Site, SitePublishingSettings + import logging + + logger = logging.getLogger(__name__) + + content_ids = request.data.get('content_ids', []) + site_id = request.data.get('site_id') + + if not content_ids: + return error_response( + error='content_ids is required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + if not site_id: + return error_response( + error='site_id is required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Get site and publishing settings + try: + site = Site.objects.get(id=site_id) + pub_settings = SitePublishingSettings.objects.filter(site=site).first() + except Site.DoesNotExist: + return error_response( + error=f'Site {site_id} not found', + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) + + # Default settings if none exist + base_time_str = '09:00 AM' + stagger_interval = 15 # minutes + timezone_str = 'America/New_York' + + if pub_settings: + base_time_str = pub_settings.auto_publish_time or base_time_str + stagger_interval = pub_settings.stagger_interval_minutes or stagger_interval + timezone_str = pub_settings.timezone or timezone_str + + # Get content items + content_qs = self.get_queryset().filter(id__in=content_ids) + + # Generate schedule preview + schedule_preview = [] + now = timezone.now() + + # Parse base time (format: "09:00 AM" or "14:30") + try: + from datetime import datetime + if 'AM' in base_time_str or 'PM' in base_time_str: + time_obj = datetime.strptime(base_time_str, '%I:%M %p').time() + else: + time_obj = datetime.strptime(base_time_str, '%H:%M').time() + except ValueError: + time_obj = datetime.strptime('09:00', '%H:%M').time() + + # Start from tomorrow at base time + start_date = now.replace(hour=time_obj.hour, minute=time_obj.minute, second=0, microsecond=0) + if start_date <= now: + start_date += timedelta(days=1) + + # Create schedule for each content item + for index, content in enumerate(content_qs): + scheduled_at = start_date + timedelta(minutes=stagger_interval * index) + schedule_preview.append({ + 'content_id': content.id, + 'title': content.title, + 'scheduled_at': scheduled_at.isoformat(), + }) + + logger.info(f"[bulk_schedule_preview] Generated preview for {len(schedule_preview)} items") + + return success_response( + data={ + 'scheduled_count': len(schedule_preview), + 'schedule_preview': schedule_preview, + 'site_settings': { + 'base_time': base_time_str, + 'stagger_interval': stagger_interval, + 'timezone': timezone_str, + }, + }, + message=f'Preview generated for {len(schedule_preview)} items', + request=request + ) + + @action(detail=False, methods=['post'], url_path='bulk_schedule', url_name='bulk_schedule', permission_classes=[IsAuthenticatedAndActive, IsEditorOrAbove]) + def bulk_schedule(self, request): + """ + Bulk schedule multiple content items using site default settings. + + POST /api/v1/writer/content/bulk_schedule/ + { + "content_ids": [123, 124, 125], + "use_site_defaults": true, + "site_id": 45 + } + """ + from django.utils import timezone + from datetime import timedelta + from igny8_core.business.integration.models import Site, SitePublishingSettings + import logging + + logger = logging.getLogger(__name__) + + content_ids = request.data.get('content_ids', []) + use_site_defaults = request.data.get('use_site_defaults', True) + site_id = request.data.get('site_id') + + if not content_ids: + return error_response( + error='content_ids is required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + if not site_id: + return error_response( + error='site_id is required', + status_code=status.HTTP_400_BAD_REQUEST, + request=request + ) + + # Get site and publishing settings + try: + site = Site.objects.get(id=site_id) + pub_settings = SitePublishingSettings.objects.filter(site=site).first() + except Site.DoesNotExist: + return error_response( + error=f'Site {site_id} not found', + status_code=status.HTTP_404_NOT_FOUND, + request=request + ) + + # Default settings if none exist + base_time_str = '09:00 AM' + stagger_interval = 15 # minutes + + if pub_settings and use_site_defaults: + base_time_str = pub_settings.auto_publish_time or base_time_str + stagger_interval = pub_settings.stagger_interval_minutes or stagger_interval + + # Get content items + content_qs = self.get_queryset().filter(id__in=content_ids) + + # Generate schedule and apply + now = timezone.now() + + # Parse base time + try: + from datetime import datetime + if 'AM' in base_time_str or 'PM' in base_time_str: + time_obj = datetime.strptime(base_time_str, '%I:%M %p').time() + else: + time_obj = datetime.strptime(base_time_str, '%H:%M').time() + except ValueError: + time_obj = datetime.strptime('09:00', '%H:%M').time() + + # Start from tomorrow at base time + start_date = now.replace(hour=time_obj.hour, minute=time_obj.minute, second=0, microsecond=0) + if start_date <= now: + start_date += timedelta(days=1) + + # Schedule each content item + scheduled_count = 0 + for index, content in enumerate(content_qs): + scheduled_at = start_date + timedelta(minutes=stagger_interval * index) + content.site_status = 'scheduled' + content.scheduled_publish_at = scheduled_at + content.site_status_updated_at = now + content.save(update_fields=['site_status', 'scheduled_publish_at', 'site_status_updated_at', 'updated_at']) + scheduled_count += 1 + + logger.info(f"[bulk_schedule] Scheduled {scheduled_count} content items") + + return success_response( + data={ + 'scheduled_count': scheduled_count, + }, + message=f'Successfully scheduled {scheduled_count} items', + request=request + ) + @action(detail=False, methods=['post'], url_path='generate_image_prompts', url_name='generate_image_prompts') def generate_image_prompts(self, request): """Generate image prompts for content records - same pattern as other AI functions""" diff --git a/docs/plans/ACCURATE-INTEGRATION-SECURITY-PLAN.md b/docs/plans/implemented/ACCURATE-INTEGRATION-SECURITY-PLAN.md similarity index 100% rename from docs/plans/ACCURATE-INTEGRATION-SECURITY-PLAN.md rename to docs/plans/implemented/ACCURATE-INTEGRATION-SECURITY-PLAN.md diff --git a/docs/plans/HIGH_OPPORTUNITY_KEYWORDS_ADD_KEYWORDS_PAGE.md b/docs/plans/implemented/HIGH_OPPORTUNITY_KEYWORDS_ADD_KEYWORDS_PAGE.md similarity index 100% rename from docs/plans/HIGH_OPPORTUNITY_KEYWORDS_ADD_KEYWORDS_PAGE.md rename to docs/plans/implemented/HIGH_OPPORTUNITY_KEYWORDS_ADD_KEYWORDS_PAGE.md diff --git a/docs/plans/WP_PLUGIN_IGNY8_APP_CLEANUP.md b/docs/plans/implemented/WP_PLUGIN_IGNY8_APP_CLEANUP.md similarity index 100% rename from docs/plans/WP_PLUGIN_IGNY8_APP_CLEANUP.md rename to docs/plans/implemented/WP_PLUGIN_IGNY8_APP_CLEANUP.md diff --git a/docs/plans/WP_PLUGIN_IGNY8_APP_CLEANUP_IMPLEMENTATION_SUMMARY.md b/docs/plans/implemented/WP_PLUGIN_IGNY8_APP_CLEANUP_IMPLEMENTATION_SUMMARY.md similarity index 100% rename from docs/plans/WP_PLUGIN_IGNY8_APP_CLEANUP_IMPLEMENTATION_SUMMARY.md rename to docs/plans/implemented/WP_PLUGIN_IGNY8_APP_CLEANUP_IMPLEMENTATION_SUMMARY.md diff --git a/frontend/src/components/common/BulkPublishingModal.tsx b/frontend/src/components/common/BulkPublishingModal.tsx new file mode 100644 index 00000000..8c5a5792 --- /dev/null +++ b/frontend/src/components/common/BulkPublishingModal.tsx @@ -0,0 +1,272 @@ +/** + * BulkPublishingModal - Displays bulk content publishing progress with queue + * Platform-agnostic: Works with WordPress, Shopify, Custom sites + * Similar pattern to ImageQueueModal + */ + +import React, { useEffect, useState, useRef } from 'react'; +import { Modal } from '../ui/modal'; +import { BoltIcon, CheckCircleIcon, ErrorIcon, ClockIcon, GlobeIcon } from '../../icons'; +import Button from '../ui/button/Button'; + +export interface PublishQueueItem { + contentId: number; + contentTitle: string; + index: number; + status: 'pending' | 'processing' | 'completed' | 'failed'; + progress: number; // 0-100 + statusMessage: string; + error: string | null; + externalUrl: string | null; + externalId: string | null; +} + +interface BulkPublishingModalProps { + isOpen: boolean; + onClose: () => void; + queue: PublishQueueItem[]; + siteName: string; + destination: string; // Platform type + onUpdateQueue?: (queue: PublishQueueItem[]) => void; + onRetryItem?: (contentId: number) => void; +} + +export default function BulkPublishingModal({ + isOpen, + onClose, + queue, + siteName, + destination, + onUpdateQueue, + onRetryItem, +}: BulkPublishingModalProps) { + const [localQueue, setLocalQueue] = useState(queue); + const [smoothProgress, setSmoothProgress] = useState>({}); + const progressIntervalsRef = useRef>>({}); + + useEffect(() => { + setLocalQueue(queue); + }, [queue]); + + useEffect(() => { + if (onUpdateQueue) { + onUpdateQueue(localQueue); + } + }, [localQueue, onUpdateQueue]); + + // Smooth progress animation for each item + useEffect(() => { + localQueue.forEach(item => { + const currentSmooth = smoothProgress[item.index] || 0; + const targetProgress = item.progress; + + if (item.status === 'processing' && targetProgress > currentSmooth) { + // Clear existing interval for this item + if (progressIntervalsRef.current[item.index]) { + clearInterval(progressIntervalsRef.current[item.index]); + } + + // Animate progress + const progressDiff = targetProgress - currentSmooth; + const steps = Math.max(10, progressDiff); + const interval = 50; // ms per step + + let step = 0; + progressIntervalsRef.current[item.index] = setInterval(() => { + step++; + const newProgress = currentSmooth + (progressDiff * step) / steps; + + if (step >= steps) { + setSmoothProgress(prev => ({ ...prev, [item.index]: targetProgress })); + if (progressIntervalsRef.current[item.index]) { + clearInterval(progressIntervalsRef.current[item.index]); + delete progressIntervalsRef.current[item.index]; + } + } else { + setSmoothProgress(prev => ({ ...prev, [item.index]: newProgress })); + } + }, interval); + } else if (item.status === 'completed' || item.status === 'failed') { + // Set to final value immediately + setSmoothProgress(prev => ({ ...prev, [item.index]: item.progress })); + if (progressIntervalsRef.current[item.index]) { + clearInterval(progressIntervalsRef.current[item.index]); + delete progressIntervalsRef.current[item.index]; + } + } + }); + + return () => { + // Cleanup all intervals on unmount + Object.values(progressIntervalsRef.current).forEach(interval => clearInterval(interval)); + progressIntervalsRef.current = {}; + }; + }, [localQueue]); + + const getItemIcon = (status: PublishQueueItem['status']) => { + switch (status) { + case 'completed': + return ; + case 'failed': + return ; + case 'processing': + return ; + default: + return ; + } + }; + + const getProgressBarColor = (status: PublishQueueItem['status']) => { + switch (status) { + case 'completed': + return 'bg-success-500'; + case 'failed': + return 'bg-error-500'; + case 'processing': + return 'bg-brand-500'; + default: + return 'bg-gray-300'; + } + }; + + const completedCount = localQueue.filter(item => item.status === 'completed').length; + const failedCount = localQueue.filter(item => item.status === 'failed').length; + const pendingCount = localQueue.filter(item => item.status === 'pending').length; + const processingCount = localQueue.filter(item => item.status === 'processing').length; + + const allComplete = processingCount === 0 && pendingCount === 0; + const canClose = allComplete; + + return ( + +
+ {/* Header */} +
+ +
+

+ Publishing Content +

+

+ Publishing {localQueue.length} article{localQueue.length !== 1 ? 's' : ''} to {siteName} +

+
+
+ + {/* Queue Items */} +
+ {localQueue.map((item) => { + const itemProgress = smoothProgress[item.index] || item.progress; + + return ( +
+
+ {getItemIcon(item.status)} +
+
+

+ {item.index}. {item.contentTitle} +

+ + {Math.round(itemProgress)}% + +
+ + {/* Progress Bar */} +
+
+
+
+
+ + {/* Status Message */} +
+

+ {item.statusMessage} +

+ + {/* Action Buttons */} +
+ {item.status === 'completed' && item.externalUrl && ( + + )} + {item.status === 'failed' && onRetryItem && ( + + )} +
+
+ + {/* Error Message */} + {item.status === 'failed' && item.error && ( +
+ {item.error} +
+ )} +
+
+
+ ); + })} +
+ + {/* Summary Footer */} +
+
+ {completedCount > 0 && {completedCount} completed} + {failedCount > 0 && ( + <> + {completedCount > 0 && ', '} + {failedCount} failed + + )} + {pendingCount > 0 && ( + <> + {(completedCount > 0 || failedCount > 0) && ', '} + {pendingCount} pending + + )} + {processingCount > 0 && ( + <> + {(completedCount > 0 || failedCount > 0 || pendingCount > 0) && ', '} + {processingCount} publishing... + + )} +
+ + {canClose && ( + + )} +
+ + {/* Cannot close notice */} + {!canClose && ( +

+ Please wait while all items are processed... +

+ )} +
+ + ); +} diff --git a/frontend/src/components/common/BulkScheduleModal.tsx b/frontend/src/components/common/BulkScheduleModal.tsx new file mode 100644 index 00000000..147ad218 --- /dev/null +++ b/frontend/src/components/common/BulkScheduleModal.tsx @@ -0,0 +1,202 @@ +import React, { useState, useEffect } from 'react'; +import { Modal } from '../ui/modal'; +import Button from '../ui/button/Button'; +import { CalendarIcon, ClockIcon, ErrorIcon } from '../../icons'; + +interface Content { + id: number; + title: string; +} + +interface BulkScheduleModalProps { + isOpen: boolean; + onClose: () => void; + contentItems: Content[]; + onSchedule: (contentIds: number[], scheduledDate: string) => Promise; +} + +const BulkScheduleModal: React.FC = ({ + isOpen, + onClose, + contentItems, + onSchedule +}) => { + const [selectedDate, setSelectedDate] = useState(''); + const [selectedTime, setSelectedTime] = useState('09:00'); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + if (isOpen) { + // Default to tomorrow at 9:00 AM + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const dateStr = tomorrow.toISOString().split('T')[0]; + setSelectedDate(dateStr); + setSelectedTime('09:00'); + } + }, [isOpen]); + + const handleSubmit = async () => { + if (!selectedDate || !selectedTime || contentItems.length === 0) return; + + // Combine date and time into ISO string + const scheduledDateTime = new Date(`${selectedDate}T${selectedTime}`); + + // Validate future date + if (scheduledDateTime <= new Date()) { + alert('Please select a future date and time'); + return; + } + + setIsSubmitting(true); + try { + const contentIds = contentItems.map(item => item.id); + await onSchedule(contentIds, scheduledDateTime.toISOString()); + onClose(); + } catch (error) { + console.error('Failed to schedule:', error); + } finally { + setIsSubmitting(false); + } + }; + + const formatPreviewDate = () => { + if (!selectedDate || !selectedTime) return ''; + + try { + const dateTime = new Date(`${selectedDate}T${selectedTime}`); + return dateTime.toLocaleString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + } catch (error) { + return ''; + } + }; + + return ( + +
+ {/* Header */} +
+ +
+

+ Schedule {contentItems.length} Article{contentItems.length !== 1 ? 's' : ''} +

+

+ Schedule all selected articles for the same date and time +

+
+
+ + {/* Date/Time Selection */} +
+ {/* Date Picker */} +
+ +
+ setSelectedDate(e.target.value)} + min={new Date().toISOString().split('T')[0]} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" + disabled={isSubmitting} + /> + +
+
+ + {/* Time Picker */} +
+ +
+ setSelectedTime(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" + disabled={isSubmitting} + /> + +
+
+ + {/* Preview */} + {selectedDate && selectedTime && ( +
+

+ Preview: {formatPreviewDate()} +

+
+ )} + + {/* Warning */} +
+
+ +

+ All {contentItems.length} article{contentItems.length !== 1 ? 's' : ''} will be scheduled for the same date and time. +

+
+
+ + {/* Content List */} + {contentItems.length > 0 && ( +
+

+ Selected Articles: +

+
    + {contentItems.slice(0, 10).map((item, index) => ( +
  • + {index + 1}. {item.title} +
  • + ))} + {contentItems.length > 10 && ( +
  • + ... and {contentItems.length - 10} more +
  • + )} +
+
+ )} +
+ + {/* Actions */} +
+ + +
+
+
+ ); +}; + +export default BulkScheduleModal; diff --git a/frontend/src/components/common/BulkSchedulePreviewModal.tsx b/frontend/src/components/common/BulkSchedulePreviewModal.tsx new file mode 100644 index 00000000..84a56bfd --- /dev/null +++ b/frontend/src/components/common/BulkSchedulePreviewModal.tsx @@ -0,0 +1,199 @@ +import React from 'react'; +import { Modal } from '../ui/modal'; +import Button from '../ui/button/Button'; +import { CalendarIcon, InfoIcon, ExternalLinkIcon } from '../../icons'; + +interface SchedulePreviewItem { + content_id: number; + scheduled_at: string; + title: string; +} + +interface SiteSettings { + base_time: string; + stagger_interval: number; + timezone: string; +} + +interface BulkSchedulePreviewData { + scheduled_count: number; + schedule_preview: SchedulePreviewItem[]; + site_settings: SiteSettings; +} + +interface BulkSchedulePreviewModalProps { + isOpen: boolean; + onClose: () => void; + previewData: BulkSchedulePreviewData | null; + onConfirm: () => Promise; + onChangeSettings: () => void; + siteId: number; + isSubmitting?: boolean; +} + +const BulkSchedulePreviewModal: React.FC = ({ + isOpen, + onClose, + previewData, + onConfirm, + onChangeSettings, + siteId, + isSubmitting = false +}) => { + if (!previewData) return null; + + const formatDate = (isoString: string) => { + try { + const date = new Date(isoString); + return date.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + } catch (error) { + return isoString; + } + }; + + const formatFullDate = (isoString: string) => { + try { + const date = new Date(isoString); + return date.toLocaleString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + } catch (error) { + return isoString; + } + }; + + const firstPublish = previewData.schedule_preview[0]; + const lastPublish = previewData.schedule_preview[previewData.schedule_preview.length - 1]; + + return ( + +
+ {/* Header */} +
+ +
+

+ Schedule {previewData.scheduled_count} Article{previewData.scheduled_count !== 1 ? 's' : ''} +

+

+ Using site default schedule +

+
+
+ + {/* Site Settings Summary */} +
+

Using site default schedule:

+
    +
  • • Start time: {previewData.site_settings.base_time} ({previewData.site_settings.timezone})
  • +
  • • Stagger: {previewData.site_settings.stagger_interval} minutes between each
  • +
  • • First publish: {formatFullDate(firstPublish.scheduled_at)}
  • +
  • • Last publish: {formatFullDate(lastPublish.scheduled_at)}
  • +
+
+ + {/* Schedule Preview */} +
+

Schedule Preview:

+
+
+ + + + + + + + + + {previewData.schedule_preview.map((item, index) => ( + + + + + + ))} + +
+ # + + Article + + Scheduled Time +
+ {index + 1} + + {item.title} + + {formatDate(item.scheduled_at)} +
+
+
+
+ + {/* Info Box */} +
+
+ +
+

+ Modify defaults at{' '} + +

+
+
+
+ + {/* Actions */} +
+ + + +
+
+
+ ); +}; + +export default BulkSchedulePreviewModal; diff --git a/frontend/src/components/common/PublishLimitModal.tsx b/frontend/src/components/common/PublishLimitModal.tsx new file mode 100644 index 00000000..aefdaec7 --- /dev/null +++ b/frontend/src/components/common/PublishLimitModal.tsx @@ -0,0 +1,78 @@ +/** + * PublishLimitModal - Informs user about 5-item publishing limit + * Suggests using scheduling for larger batches + */ + +import React from 'react'; +import { Modal } from '../ui/modal'; +import { ErrorIcon, CalendarIcon, InfoIcon } from '../../icons'; +import Button from '../ui/button/Button'; + +interface PublishLimitModalProps { + isOpen: boolean; + onClose: () => void; + selectedCount: number; + onScheduleInstead: () => void; +} + +export default function PublishLimitModal({ + isOpen, + onClose, + selectedCount, + onScheduleInstead, +}: PublishLimitModalProps) { + return ( + +
+ {/* Warning Icon */} +
+ +
+ + {/* Title */} +

+ Publishing Limit Exceeded +

+ + {/* Description */} +

+ You can publish only 5 content pages to site directly. +

+

+ You have selected {selectedCount} items. +

+ + {/* Options Box */} +
+

Options:

+
    +
  • Deselect items to publish 5 or fewer
  • +
  • Use "Schedule Selected" to schedule all items
  • +
+ + {/* Tip */} +
+ +

+ Tip: Scheduling has no limit and uses your site's default publishing schedule. +

+
+
+ + {/* Action Buttons */} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/common/PublishingProgressModal.tsx b/frontend/src/components/common/PublishingProgressModal.tsx new file mode 100644 index 00000000..f4bb4f4f --- /dev/null +++ b/frontend/src/components/common/PublishingProgressModal.tsx @@ -0,0 +1,226 @@ +/** + * PublishingProgressModal - Displays single content publishing progress + * Platform-agnostic: Works with WordPress, Shopify, Custom sites + */ + +import React, { useEffect, useState, useRef } from 'react'; +import { Modal } from '../ui/modal'; +import { BoltIcon, CheckCircleIcon, ErrorIcon, GlobeIcon } from '../../icons'; +import Button from '../ui/button/Button'; + +export interface PublishingProgressState { + contentId: number; + contentTitle: string; + destination: string; // Platform type: 'wordpress', 'shopify', 'custom' + siteName: string; // Actual site name for display + status: 'preparing' | 'uploading' | 'processing' | 'finalizing' | 'completed' | 'failed'; + progress: number; // 0-100 + statusMessage: string; + error: string | null; + externalUrl: string | null; // Published URL on success + externalId: string | null; // External platform post/page ID +} + +interface PublishingProgressModalProps { + isOpen: boolean; + onClose: () => void; + publishingState: PublishingProgressState; + onRetry?: () => void; +} + +export default function PublishingProgressModal({ + isOpen, + onClose, + publishingState, + onRetry, +}: PublishingProgressModalProps) { + const [smoothProgress, setSmoothProgress] = useState(0); + const progressIntervalRef = useRef | null>(null); + + useEffect(() => { + if (publishingState.status === 'completed' || publishingState.status === 'failed') { + // Clear any running intervals + if (progressIntervalRef.current) { + clearInterval(progressIntervalRef.current); + progressIntervalRef.current = null; + } + setSmoothProgress(publishingState.progress); + return; + } + + // Animate progress smoothly based on stage + const targetProgress = publishingState.progress; + const currentProgress = smoothProgress; + + if (targetProgress > currentProgress) { + if (progressIntervalRef.current) { + clearInterval(progressIntervalRef.current); + } + + // Different animation speeds for different stages + const progressDiff = targetProgress - currentProgress; + const steps = progressDiff > 50 ? 50 : progressDiff; + const interval = progressDiff > 50 ? 50 : 100; // ms per step + + let step = 0; + progressIntervalRef.current = setInterval(() => { + step++; + const newProgress = currentProgress + (progressDiff * step) / steps; + + if (step >= steps) { + setSmoothProgress(targetProgress); + if (progressIntervalRef.current) { + clearInterval(progressIntervalRef.current); + progressIntervalRef.current = null; + } + } else { + setSmoothProgress(newProgress); + } + }, interval); + } + + return () => { + if (progressIntervalRef.current) { + clearInterval(progressIntervalRef.current); + progressIntervalRef.current = null; + } + }; + }, [publishingState.progress, publishingState.status]); + + const getStatusIcon = () => { + switch (publishingState.status) { + case 'completed': + return ; + case 'failed': + return ; + default: + return ; + } + }; + + const getStatusColor = () => { + switch (publishingState.status) { + case 'completed': + return 'bg-success-500'; + case 'failed': + return 'bg-error-500'; + default: + return 'bg-brand-500'; + } + }; + + const canClose = publishingState.status === 'completed' || publishingState.status === 'failed'; + + const handleViewOnSite = () => { + if (publishingState.externalUrl) { + window.open(publishingState.externalUrl, '_blank', 'noopener,noreferrer'); + } + }; + + return ( + +
+ {/* Header */} +
+ {getStatusIcon()} +
+

+ {publishingState.status === 'completed' && 'Published Successfully'} + {publishingState.status === 'failed' && 'Publishing Failed'} + {!['completed', 'failed'].includes(publishingState.status) && 'Publishing Content'} +

+

+ Publishing "{publishingState.contentTitle}" to {publishingState.siteName} +

+
+
+ + {/* Progress Bar */} +
+
+
+
+
+ + {publishingState.statusMessage} + + + {Math.round(smoothProgress)}% + +
+
+ + {/* Error Message */} + {publishingState.status === 'failed' && publishingState.error && ( +
+
+ +
+

+ Publishing Error +

+

+ {publishingState.error} +

+
+
+
+ )} + + {/* Success Message */} + {publishingState.status === 'completed' && publishingState.externalUrl && ( +
+
+ +
+

+ Content published successfully! +

+

+ Your content is now live on {publishingState.siteName} +

+
+
+
+ )} + + {/* Action Buttons */} +
+ {publishingState.status === 'failed' && onRetry && ( + + )} + {publishingState.status === 'completed' && publishingState.externalUrl && ( + + )} + {canClose && ( + + )} +
+ + {/* Cannot close notice */} + {!canClose && ( +

+ Please wait while publishing completes... +

+ )} +
+ + ); +} diff --git a/frontend/src/components/common/ScheduleContentModal.tsx b/frontend/src/components/common/ScheduleContentModal.tsx new file mode 100644 index 00000000..9ebc69d0 --- /dev/null +++ b/frontend/src/components/common/ScheduleContentModal.tsx @@ -0,0 +1,185 @@ +import React, { useState, useEffect } from 'react'; +import { Modal } from '../ui/modal'; +import Button from '../ui/button/Button'; +import { CalendarIcon, ClockIcon } from '../../icons'; + +interface Content { + id: number; + title: string; + scheduled_publish_at?: string | null; +} + +interface ScheduleContentModalProps { + isOpen: boolean; + onClose: () => void; + content: Content | null; + onSchedule: (contentId: number, scheduledDate: string) => Promise; + mode?: 'schedule' | 'reschedule'; +} + +const ScheduleContentModal: React.FC = ({ + isOpen, + onClose, + content, + onSchedule, + mode = 'schedule' +}) => { + const [selectedDate, setSelectedDate] = useState(''); + const [selectedTime, setSelectedTime] = useState('09:00'); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + if (isOpen && content) { + if (mode === 'reschedule' && content.scheduled_publish_at) { + // Pre-fill with existing schedule + const existingDate = new Date(content.scheduled_publish_at); + const dateStr = existingDate.toISOString().split('T')[0]; + const hours = existingDate.getHours().toString().padStart(2, '0'); + const minutes = existingDate.getMinutes().toString().padStart(2, '0'); + setSelectedDate(dateStr); + setSelectedTime(`${hours}:${minutes}`); + } else { + // Default to tomorrow at 9:00 AM + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const dateStr = tomorrow.toISOString().split('T')[0]; + setSelectedDate(dateStr); + setSelectedTime('09:00'); + } + } + }, [isOpen, content, mode]); + + const handleSubmit = async () => { + if (!content || !selectedDate || !selectedTime) return; + + // Combine date and time into ISO string + const scheduledDateTime = new Date(`${selectedDate}T${selectedTime}`); + + // Validate future date + if (scheduledDateTime <= new Date()) { + alert('Please select a future date and time'); + return; + } + + setIsSubmitting(true); + try { + await onSchedule(content.id, scheduledDateTime.toISOString()); + onClose(); + } catch (error) { + console.error('Failed to schedule:', error); + } finally { + setIsSubmitting(false); + } + }; + + const formatPreviewDate = () => { + if (!selectedDate || !selectedTime) return ''; + + try { + const dateTime = new Date(`${selectedDate}T${selectedTime}`); + return dateTime.toLocaleString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + } catch (error) { + return ''; + } + }; + + if (!content) return null; + + return ( + +
+ {/* Header */} +
+ +
+

+ {mode === 'reschedule' ? 'Reschedule' : 'Schedule'} Content Publishing +

+

+ Content: "{content.title}" +

+
+
+ + {/* Date/Time Selection */} +
+ {/* Date Picker */} +
+ +
+ setSelectedDate(e.target.value)} + min={new Date().toISOString().split('T')[0]} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" + disabled={isSubmitting} + /> + +
+
+ + {/* Time Picker */} +
+ +
+ setSelectedTime(e.target.value)} + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent" + disabled={isSubmitting} + /> + +
+
+ + {/* Preview */} + {selectedDate && selectedTime && ( +
+

+ Preview: {formatPreviewDate()} +

+
+ )} +
+ + {/* Actions */} +
+ + +
+
+
+ ); +}; + +export default ScheduleContentModal; diff --git a/frontend/src/config/pages/table-actions.config.tsx b/frontend/src/config/pages/table-actions.config.tsx index 4697bf5e..843a3e40 100644 --- a/frontend/src/config/pages/table-actions.config.tsx +++ b/frontend/src/config/pages/table-actions.config.tsx @@ -294,14 +294,14 @@ const tableActionsConfigs: Record = { variant: 'primary', }, { - key: 'publish_wordpress', + key: 'publish_site', label: 'Publish to Site', icon: , variant: 'success', shouldShow: (row: any) => !row.external_id, // Only show if not published }, { - key: 'view_on_wordpress', + key: 'view_on_site', label: 'View on Site', icon: , variant: 'secondary', @@ -310,7 +310,7 @@ const tableActionsConfigs: Record = { ], bulkActions: [ { - key: 'bulk_publish_wordpress', + key: 'bulk_publish_site', label: 'Publish to Site', icon: , variant: 'success', @@ -387,12 +387,6 @@ const tableActionsConfigs: Record = { icon: , variant: 'success', }, - { - key: 'publish_wordpress', - label: 'Publish to Site', - icon: , - variant: 'primary', - }, ], bulkActions: [ { @@ -401,12 +395,6 @@ const tableActionsConfigs: Record = { icon: , variant: 'success', }, - { - key: 'bulk_publish_wordpress', - label: 'Publish to Site', - icon: , - variant: 'primary', - }, ], }, // Default config (fallback) diff --git a/frontend/src/pages/Writer/Approved.tsx b/frontend/src/pages/Writer/Approved.tsx index c82c9d09..7b917dcc 100644 --- a/frontend/src/pages/Writer/Approved.tsx +++ b/frontend/src/pages/Writer/Approved.tsx @@ -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(null); + const [bulkPublishQueue, setBulkPublishQueue] = useState([]); + + // Scheduling modals state + const [showScheduleModal, setShowScheduleModal] = useState(false); + const [showBulkScheduleModal, setShowBulkScheduleModal] = useState(false); + const [showBulkSchedulePreviewModal, setShowBulkSchedulePreviewModal] = useState(false); + const [scheduleContent, setScheduleContent] = useState(null); + const [bulkScheduleItems, setBulkScheduleItems] = useState([]); + const [bulkSchedulePreview, setBulkSchedulePreview] = useState(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: , - 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 && ( + setShowSinglePublishModal(false)} + publishingState={singlePublishState} + onRetry={() => { + setShowSinglePublishModal(false); + const contentItem = content.find(c => c.id === singlePublishState.contentId); + if (contentItem) { + handleSinglePublish(contentItem); + } + }} + /> + )} + + { + 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); + } + }} + /> + + setShowPublishLimitModal(false)} + selectedCount={selectedIds.length} + onScheduleInstead={() => { + setShowPublishLimitModal(false); + handleBulkScheduleWithDefaults(); + }} + /> + + { + 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'} + /> + + { + setShowBulkScheduleModal(false); + setBulkScheduleItems([]); + }} + contentItems={bulkScheduleItems} + onSchedule={async (contentIds, scheduledDate) => { + await handleBulkScheduleManual(contentIds, scheduledDate); + setShowBulkScheduleModal(false); + setBulkScheduleItems([]); + setSelectedIds([]); + }} + /> + + { + setShowBulkSchedulePreviewModal(false); + setBulkSchedulePreview(null); + }} + previewData={bulkSchedulePreview} + onConfirm={handleConfirmBulkSchedule} + onChangeSettings={handleOpenSiteSettings} + siteId={activeSite?.id || 0} + /> ); } diff --git a/frontend/src/pages/Writer/Review.tsx b/frontend/src/pages/Writer/Review.tsx index e2c37416..c8687d38 100644 --- a/frontend/src/pages/Writer/Review.tsx +++ b/frontend/src/pages/Writer/Review.tsx @@ -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"