Phase 1-3 Implemented - PUBLISHING-PROGRESS-AND-SCHEDULING-UX-PLAN
This commit is contained in:
@@ -1589,6 +1589,207 @@ class ContentViewSet(SiteSectorModelViewSet):
|
|||||||
request=request
|
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')
|
@action(detail=False, methods=['post'], url_path='generate_image_prompts', url_name='generate_image_prompts')
|
||||||
def generate_image_prompts(self, request):
|
def generate_image_prompts(self, request):
|
||||||
"""Generate image prompts for content records - same pattern as other AI functions"""
|
"""Generate image prompts for content records - same pattern as other AI functions"""
|
||||||
|
|||||||
272
frontend/src/components/common/BulkPublishingModal.tsx
Normal file
272
frontend/src/components/common/BulkPublishingModal.tsx
Normal file
@@ -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<PublishQueueItem[]>(queue);
|
||||||
|
const [smoothProgress, setSmoothProgress] = useState<Record<number, number>>({});
|
||||||
|
const progressIntervalsRef = useRef<Record<number, ReturnType<typeof setInterval>>>({});
|
||||||
|
|
||||||
|
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 <CheckCircleIcon className="w-5 h-5 text-success-500" />;
|
||||||
|
case 'failed':
|
||||||
|
return <ErrorIcon className="w-5 h-5 text-error-500" />;
|
||||||
|
case 'processing':
|
||||||
|
return <BoltIcon className="w-5 h-5 text-brand-500 animate-pulse" />;
|
||||||
|
default:
|
||||||
|
return <ClockIcon className="w-5 h-5 text-gray-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
showCloseButton={canClose}
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<BoltIcon className="w-8 h-8 text-brand-500" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
Publishing Content
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Publishing {localQueue.length} article{localQueue.length !== 1 ? 's' : ''} to {siteName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Queue Items */}
|
||||||
|
<div className="space-y-3 mb-6 max-h-[400px] overflow-y-auto">
|
||||||
|
{localQueue.map((item) => {
|
||||||
|
const itemProgress = smoothProgress[item.index] || item.progress;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.contentId}
|
||||||
|
className="p-4 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{getItemIcon(item.status)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||||
|
{item.index}. {item.contentTitle}
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs font-medium text-gray-600 dark:text-gray-400 ml-2">
|
||||||
|
{Math.round(itemProgress)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="bg-gray-200 dark:bg-gray-700 rounded-full h-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all duration-300 ${getProgressBarColor(item.status)}`}
|
||||||
|
style={{ width: `${itemProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Message */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
{item.statusMessage}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{item.status === 'completed' && item.externalUrl && (
|
||||||
|
<button
|
||||||
|
onClick={() => window.open(item.externalUrl!, '_blank', 'noopener,noreferrer')}
|
||||||
|
className="text-xs text-brand-600 hover:text-brand-700 dark:text-brand-400 hover:underline"
|
||||||
|
>
|
||||||
|
View Live
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{item.status === 'failed' && onRetryItem && (
|
||||||
|
<button
|
||||||
|
onClick={() => onRetryItem(item.contentId)}
|
||||||
|
className="text-xs text-error-600 hover:text-error-700 dark:text-error-400 hover:underline"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{item.status === 'failed' && item.error && (
|
||||||
|
<div className="mt-2 p-2 bg-error-50 dark:bg-error-900/20 border border-error-200 dark:border-error-800 rounded text-xs text-error-800 dark:text-error-200">
|
||||||
|
{item.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Footer */}
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{completedCount > 0 && <span className="text-success-600 dark:text-success-400 font-medium">{completedCount} completed</span>}
|
||||||
|
{failedCount > 0 && (
|
||||||
|
<>
|
||||||
|
{completedCount > 0 && ', '}
|
||||||
|
<span className="text-error-600 dark:text-error-400 font-medium">{failedCount} failed</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{pendingCount > 0 && (
|
||||||
|
<>
|
||||||
|
{(completedCount > 0 || failedCount > 0) && ', '}
|
||||||
|
<span className="text-gray-600 dark:text-gray-400 font-medium">{pendingCount} pending</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{processingCount > 0 && (
|
||||||
|
<>
|
||||||
|
{(completedCount > 0 || failedCount > 0 || pendingCount > 0) && ', '}
|
||||||
|
<span className="text-brand-600 dark:text-brand-400 font-medium">{processingCount} publishing...</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canClose && (
|
||||||
|
<Button variant="primary" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cannot close notice */}
|
||||||
|
{!canClose && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 text-center mt-4">
|
||||||
|
Please wait while all items are processed...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
frontend/src/components/common/BulkScheduleModal.tsx
Normal file
202
frontend/src/components/common/BulkScheduleModal.tsx
Normal file
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BulkScheduleModal: React.FC<BulkScheduleModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
contentItems,
|
||||||
|
onSchedule
|
||||||
|
}) => {
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string>('');
|
||||||
|
const [selectedTime, setSelectedTime] = useState<string>('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 (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
showCloseButton={!isSubmitting}
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<CalendarIcon className="w-8 h-8 text-primary-500" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">
|
||||||
|
Schedule {contentItems.length} Article{contentItems.length !== 1 ? 's' : ''}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Schedule all selected articles for the same date and time
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date/Time Selection */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Date Picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Schedule Date
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={selectedDate}
|
||||||
|
onChange={(e) => 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}
|
||||||
|
/>
|
||||||
|
<CalendarIcon className="absolute right-3 top-2.5 w-5 h-5 text-gray-400 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Schedule Time
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={selectedTime}
|
||||||
|
onChange={(e) => 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}
|
||||||
|
/>
|
||||||
|
<ClockIcon className="absolute right-3 top-2.5 w-5 h-5 text-gray-400 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
{selectedDate && selectedTime && (
|
||||||
|
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 rounded">
|
||||||
|
<p className="text-sm font-medium text-blue-900">
|
||||||
|
Preview: {formatPreviewDate()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Warning */}
|
||||||
|
<div className="bg-yellow-50 border-l-4 border-yellow-500 p-4 rounded">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<ErrorIcon className="w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-sm text-yellow-800">
|
||||||
|
All {contentItems.length} article{contentItems.length !== 1 ? 's' : ''} will be scheduled for the same date and time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content List */}
|
||||||
|
{contentItems.length > 0 && (
|
||||||
|
<div className="border border-gray-200 rounded-lg p-4 max-h-48 overflow-y-auto">
|
||||||
|
<p className="text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Selected Articles:
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{contentItems.slice(0, 10).map((item, index) => (
|
||||||
|
<li key={item.id} className="text-sm text-gray-600 truncate">
|
||||||
|
{index + 1}. {item.title}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{contentItems.length > 10 && (
|
||||||
|
<li className="text-sm text-gray-500 italic">
|
||||||
|
... and {contentItems.length - 10} more
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 mt-6 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting || !selectedDate || !selectedTime}
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Scheduling...' : 'Schedule All'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BulkScheduleModal;
|
||||||
199
frontend/src/components/common/BulkSchedulePreviewModal.tsx
Normal file
199
frontend/src/components/common/BulkSchedulePreviewModal.tsx
Normal file
@@ -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<void>;
|
||||||
|
onChangeSettings: () => void;
|
||||||
|
siteId: number;
|
||||||
|
isSubmitting?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BulkSchedulePreviewModal: React.FC<BulkSchedulePreviewModalProps> = ({
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
showCloseButton={!isSubmitting}
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<CalendarIcon className="w-8 h-8 text-primary-500" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">
|
||||||
|
Schedule {previewData.scheduled_count} Article{previewData.scheduled_count !== 1 ? 's' : ''}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Using site default schedule
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Site Settings Summary */}
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
|
||||||
|
<p className="text-sm font-semibold text-blue-900 mb-2">Using site default schedule:</p>
|
||||||
|
<ul className="space-y-1 text-sm text-blue-800">
|
||||||
|
<li>• Start time: {previewData.site_settings.base_time} ({previewData.site_settings.timezone})</li>
|
||||||
|
<li>• Stagger: {previewData.site_settings.stagger_interval} minutes between each</li>
|
||||||
|
<li>• First publish: {formatFullDate(firstPublish.scheduled_at)}</li>
|
||||||
|
<li>• Last publish: {formatFullDate(lastPublish.scheduled_at)}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Schedule Preview */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-sm font-semibold text-gray-700 mb-3">Schedule Preview:</p>
|
||||||
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<div className="max-h-80 overflow-y-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
#
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Article
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Scheduled Time
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{previewData.schedule_preview.map((item, index) => (
|
||||||
|
<tr key={item.content_id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-500">
|
||||||
|
{index + 1}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900 truncate max-w-md">
|
||||||
|
{item.title}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-600">
|
||||||
|
{formatDate(item.scheduled_at)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className="bg-gray-50 border-l-4 border-gray-400 p-4 mb-6 rounded">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<InfoIcon className="w-5 h-5 text-gray-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="text-sm text-gray-700">
|
||||||
|
<p>
|
||||||
|
Modify defaults at{' '}
|
||||||
|
<button
|
||||||
|
onClick={onChangeSettings}
|
||||||
|
className="text-primary-600 hover:text-primary-700 font-medium inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
Site Settings → Publishing tab
|
||||||
|
<ExternalLinkIcon className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onChangeSettings}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Change Settings
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Scheduling...' : 'Confirm Schedule'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BulkSchedulePreviewModal;
|
||||||
78
frontend/src/components/common/PublishLimitModal.tsx
Normal file
78
frontend/src/components/common/PublishLimitModal.tsx
Normal file
@@ -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 (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose}>
|
||||||
|
<div className="p-6 text-center">
|
||||||
|
{/* Warning Icon */}
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<ErrorIcon className="w-16 h-16 text-warning-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||||
|
Publishing Limit Exceeded
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
You can publish only <strong className="text-gray-900 dark:text-white">5 content pages</strong> to site directly.
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
You have selected <strong className="text-gray-900 dark:text-white">{selectedCount} items</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Options Box */}
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border-l-4 border-blue-500 p-4 mb-6 text-left">
|
||||||
|
<p className="font-semibold text-gray-900 dark:text-white mb-3">Options:</p>
|
||||||
|
<ul className="list-disc list-inside space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
<li>Deselect items to publish 5 or fewer</li>
|
||||||
|
<li>Use "Schedule Selected" to schedule all items</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Tip */}
|
||||||
|
<div className="flex items-start gap-2 mt-4 p-3 bg-white dark:bg-gray-800 rounded">
|
||||||
|
<InfoIcon className="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
<strong>Tip:</strong> Scheduling has no limit and uses your site's default publishing schedule.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-3 justify-center">
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Go Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={onScheduleInstead}
|
||||||
|
startIcon={<CalendarIcon className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Schedule Selected
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
226
frontend/src/components/common/PublishingProgressModal.tsx
Normal file
226
frontend/src/components/common/PublishingProgressModal.tsx
Normal file
@@ -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<ReturnType<typeof setInterval> | 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 <CheckCircleIcon className="w-8 h-8 text-success-500" />;
|
||||||
|
case 'failed':
|
||||||
|
return <ErrorIcon className="w-8 h-8 text-error-500" />;
|
||||||
|
default:
|
||||||
|
return <BoltIcon className="w-8 h-8 text-brand-500 animate-pulse" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
showCloseButton={canClose}
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
{getStatusIcon()}
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
{publishingState.status === 'completed' && 'Published Successfully'}
|
||||||
|
{publishingState.status === 'failed' && 'Publishing Failed'}
|
||||||
|
{!['completed', 'failed'].includes(publishingState.status) && 'Publishing Content'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Publishing "{publishingState.contentTitle}" to {publishingState.siteName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all duration-300 ${getStatusColor()}`}
|
||||||
|
style={{ width: `${smoothProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center mt-2">
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{publishingState.statusMessage}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{Math.round(smoothProgress)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{publishingState.status === 'failed' && publishingState.error && (
|
||||||
|
<div className="mb-6 p-4 bg-error-50 dark:bg-error-900/20 border border-error-200 dark:border-error-800 rounded-lg">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<ErrorIcon className="w-5 h-5 text-error-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-error-900 dark:text-error-100 mb-1">
|
||||||
|
Publishing Error
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-error-800 dark:text-error-200">
|
||||||
|
{publishingState.error}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success Message */}
|
||||||
|
{publishingState.status === 'completed' && publishingState.externalUrl && (
|
||||||
|
<div className="mb-6 p-4 bg-success-50 dark:bg-success-900/20 border border-success-200 dark:border-success-800 rounded-lg">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<CheckCircleIcon className="w-5 h-5 text-success-500 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-success-900 dark:text-success-100 mb-1">
|
||||||
|
Content published successfully!
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-success-800 dark:text-success-200">
|
||||||
|
Your content is now live on {publishingState.siteName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
{publishingState.status === 'failed' && onRetry && (
|
||||||
|
<Button variant="outline" onClick={onRetry}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{publishingState.status === 'completed' && publishingState.externalUrl && (
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleViewOnSite}
|
||||||
|
startIcon={<GlobeIcon className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
View on {publishingState.siteName}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canClose && (
|
||||||
|
<Button variant={publishingState.status === 'completed' ? 'outline' : 'primary'} onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cannot close notice */}
|
||||||
|
{!canClose && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 text-center mt-4">
|
||||||
|
Please wait while publishing completes...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
frontend/src/components/common/ScheduleContentModal.tsx
Normal file
185
frontend/src/components/common/ScheduleContentModal.tsx
Normal file
@@ -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<void>;
|
||||||
|
mode?: 'schedule' | 'reschedule';
|
||||||
|
}
|
||||||
|
|
||||||
|
const ScheduleContentModal: React.FC<ScheduleContentModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
content,
|
||||||
|
onSchedule,
|
||||||
|
mode = 'schedule'
|
||||||
|
}) => {
|
||||||
|
const [selectedDate, setSelectedDate] = useState<string>('');
|
||||||
|
const [selectedTime, setSelectedTime] = useState<string>('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 (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
showCloseButton={!isSubmitting}
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<CalendarIcon className="w-8 h-8 text-primary-500" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900">
|
||||||
|
{mode === 'reschedule' ? 'Reschedule' : 'Schedule'} Content Publishing
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Content: "{content.title}"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date/Time Selection */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Date Picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Schedule Date
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={selectedDate}
|
||||||
|
onChange={(e) => 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}
|
||||||
|
/>
|
||||||
|
<CalendarIcon className="absolute right-3 top-2.5 w-5 h-5 text-gray-400 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Picker */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Schedule Time
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={selectedTime}
|
||||||
|
onChange={(e) => 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}
|
||||||
|
/>
|
||||||
|
<ClockIcon className="absolute right-3 top-2.5 w-5 h-5 text-gray-400 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
{selectedDate && selectedTime && (
|
||||||
|
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 rounded">
|
||||||
|
<p className="text-sm font-medium text-blue-900">
|
||||||
|
Preview: {formatPreviewDate()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 mt-6 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting || !selectedDate || !selectedTime}
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Scheduling...' : (mode === 'reschedule' ? 'Reschedule' : 'Schedule')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScheduleContentModal;
|
||||||
@@ -294,14 +294,14 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
|
|||||||
variant: 'primary',
|
variant: 'primary',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'publish_wordpress',
|
key: 'publish_site',
|
||||||
label: 'Publish to Site',
|
label: 'Publish to Site',
|
||||||
icon: <ArrowRightIcon className="w-5 h-5" />,
|
icon: <ArrowRightIcon className="w-5 h-5" />,
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
shouldShow: (row: any) => !row.external_id, // Only show if not published
|
shouldShow: (row: any) => !row.external_id, // Only show if not published
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'view_on_wordpress',
|
key: 'view_on_site',
|
||||||
label: 'View on Site',
|
label: 'View on Site',
|
||||||
icon: <CheckCircleIcon className="w-5 h-5 text-brand-500" />,
|
icon: <CheckCircleIcon className="w-5 h-5 text-brand-500" />,
|
||||||
variant: 'secondary',
|
variant: 'secondary',
|
||||||
@@ -310,7 +310,7 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
|
|||||||
],
|
],
|
||||||
bulkActions: [
|
bulkActions: [
|
||||||
{
|
{
|
||||||
key: 'bulk_publish_wordpress',
|
key: 'bulk_publish_site',
|
||||||
label: 'Publish to Site',
|
label: 'Publish to Site',
|
||||||
icon: <ArrowRightIcon className="w-4 h-4" />,
|
icon: <ArrowRightIcon className="w-4 h-4" />,
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
@@ -387,12 +387,6 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
|
|||||||
icon: <CheckCircleIcon className="w-5 h-5" />,
|
icon: <CheckCircleIcon className="w-5 h-5" />,
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'publish_wordpress',
|
|
||||||
label: 'Publish to Site',
|
|
||||||
icon: <ArrowRightIcon className="w-5 h-5" />,
|
|
||||||
variant: 'primary',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
bulkActions: [
|
bulkActions: [
|
||||||
{
|
{
|
||||||
@@ -401,12 +395,6 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
|
|||||||
icon: <CheckCircleIcon className="w-5 h-5" />,
|
icon: <CheckCircleIcon className="w-5 h-5" />,
|
||||||
variant: 'success',
|
variant: 'success',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'bulk_publish_wordpress',
|
|
||||||
label: 'Publish to Site',
|
|
||||||
icon: <ArrowRightIcon className="w-5 h-5" />,
|
|
||||||
variant: 'primary',
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// Default config (fallback)
|
// Default config (fallback)
|
||||||
|
|||||||
@@ -26,6 +26,12 @@ import { useSiteStore } from '../../store/siteStore';
|
|||||||
import { usePageSizeStore } from '../../store/pageSizeStore';
|
import { usePageSizeStore } from '../../store/pageSizeStore';
|
||||||
import PageHeader from '../../components/common/PageHeader';
|
import PageHeader from '../../components/common/PageHeader';
|
||||||
import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter';
|
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() {
|
export default function Approved() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
@@ -70,6 +76,21 @@ export default function Approved() {
|
|||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||||
const [showContent, setShowContent] = useState(false);
|
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
|
// 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
|
// This implements cascading filters - each filter's options reflect what's available
|
||||||
// given the other currently applied filters
|
// given the other currently applied filters
|
||||||
@@ -226,33 +247,240 @@ export default function Approved() {
|
|||||||
setCurrentPage(1);
|
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
|
// Row action handler
|
||||||
const handleRowAction = useCallback(async (action: string, row: Content) => {
|
const handleRowAction = useCallback(async (action: string, row: Content) => {
|
||||||
if (action === 'publish_wordpress') {
|
if (action === 'publish_site') {
|
||||||
try {
|
await handleSinglePublish(row);
|
||||||
const response = await fetchAPI('/v1/publisher/publish/', {
|
} else if (action === 'view_on_site') {
|
||||||
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 (row.external_url) {
|
if (row.external_url) {
|
||||||
window.open(row.external_url, '_blank');
|
window.open(row.external_url, '_blank');
|
||||||
} else {
|
} 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') {
|
} else if (action === 'edit') {
|
||||||
// Navigate to content editor
|
// Navigate to content editor
|
||||||
@@ -263,7 +491,7 @@ export default function Approved() {
|
|||||||
toast.warning('Unable to edit: Site information not available');
|
toast.warning('Unable to edit: Site information not available');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [toast, loadContent, navigate]);
|
}, [toast, navigate, handleSinglePublish, handleUnscheduleContent]);
|
||||||
|
|
||||||
const handleDelete = useCallback(async (id: number) => {
|
const handleDelete = useCallback(async (id: number) => {
|
||||||
await deleteContent(id);
|
await deleteContent(id);
|
||||||
@@ -276,56 +504,113 @@ export default function Approved() {
|
|||||||
return result;
|
return result;
|
||||||
}, [loadContent]);
|
}, [loadContent]);
|
||||||
|
|
||||||
// Bulk WordPress publish
|
// Handle bulk publish with progress modal and limit validation
|
||||||
const handleBulkPublishWordPress = useCallback(async (ids: string[]) => {
|
const handleBulkPublishToSite = useCallback(async (ids: string[]) => {
|
||||||
try {
|
if (!activeSite) {
|
||||||
const contentIds = ids.map(id => parseInt(id));
|
toast.error('No active site selected');
|
||||||
let successCount = 0;
|
return;
|
||||||
let failedCount = 0;
|
}
|
||||||
|
|
||||||
|
// Validate: Max 5 items for direct bulk publish
|
||||||
|
if (ids.length > 5) {
|
||||||
|
setShowPublishLimitModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Initialize queue
|
||||||
|
const queue: PublishQueueItem[] = ids.map((id, index) => {
|
||||||
|
const contentItem = content.find(c => c.id === parseInt(id));
|
||||||
|
return {
|
||||||
|
contentId: parseInt(id),
|
||||||
|
contentTitle: contentItem?.title || `Content #${id}`,
|
||||||
|
index: index + 1,
|
||||||
|
status: 'pending' as const,
|
||||||
|
progress: 0,
|
||||||
|
statusMessage: 'Pending',
|
||||||
|
error: null,
|
||||||
|
externalUrl: null,
|
||||||
|
externalId: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setBulkPublishQueue(queue);
|
||||||
|
setShowBulkPublishModal(true);
|
||||||
|
|
||||||
|
// Process sequentially
|
||||||
|
for (let i = 0; i < queue.length; i++) {
|
||||||
|
// Update status to processing
|
||||||
|
setBulkPublishQueue(prev => prev.map((item, idx) =>
|
||||||
|
idx === i ? { ...item, status: 'processing', progress: 0, statusMessage: 'Preparing...' } : item
|
||||||
|
));
|
||||||
|
|
||||||
// Publish each item individually
|
|
||||||
for (const contentId of contentIds) {
|
|
||||||
try {
|
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/', {
|
const response = await fetchAPI('/v1/publisher/publish/', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
content_id: contentId,
|
content_id: queue[i].contentId,
|
||||||
destinations: ['wordpress']
|
destinations: [activeSite.platform_type || 'wordpress']
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.success) {
|
// Handle response
|
||||||
successCount++;
|
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 {
|
} else {
|
||||||
failedCount++;
|
throw new Error(response.error || 'Unknown error');
|
||||||
console.warn(`Failed to publish content ${contentId}:`, response.error);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
failedCount++;
|
console.error(`Error publishing content ${queue[i].contentId}:`, error);
|
||||||
console.error(`Error publishing content ${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) {
|
// Refresh content after all done
|
||||||
toast.success(`Published ${successCount} item(s) to WordPress`);
|
|
||||||
}
|
|
||||||
if (failedCount > 0) {
|
|
||||||
toast.warning(`${failedCount} item(s) failed to publish`);
|
|
||||||
}
|
|
||||||
|
|
||||||
loadContent();
|
loadContent();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(`Failed to bulk publish: ${error.message}`);
|
toast.error(`Failed to bulk publish: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, [toast, loadContent]);
|
}, [activeSite, content, toast, loadContent]);
|
||||||
|
|
||||||
// Bulk action handler
|
// Bulk action handler
|
||||||
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
|
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
|
||||||
if (action === 'bulk_publish_wordpress') {
|
if (action === 'bulk_publish_site') {
|
||||||
await handleBulkPublishWordPress(ids);
|
await handleBulkPublishToSite(ids);
|
||||||
}
|
}
|
||||||
}, [handleBulkPublishWordPress]);
|
}, [handleBulkPublishToSite]);
|
||||||
|
|
||||||
// Bulk status update handler
|
// Bulk status update handler
|
||||||
const handleBulkUpdateStatus = useCallback(async (ids: string[], status: string) => {
|
const handleBulkUpdateStatus = useCallback(async (ids: string[], status: string) => {
|
||||||
@@ -430,10 +715,16 @@ export default function Approved() {
|
|||||||
content_structure: contentStructureFilter,
|
content_structure: contentStructureFilter,
|
||||||
}}
|
}}
|
||||||
primaryAction={{
|
primaryAction={{
|
||||||
label: 'Publish to Site',
|
label: selectedIds.length > 5 ? 'Publish (Limit Exceeded)' : 'Publish to Site',
|
||||||
icon: <BoltIcon className="w-4 h-4" />,
|
icon: <BoltIcon className="w-4 h-4" />,
|
||||||
onClick: () => handleBulkAction('bulk_publish_wordpress', selectedIds),
|
onClick: () => handleBulkAction('bulk_publish_site', selectedIds),
|
||||||
variant: 'success',
|
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) => {
|
onFilterChange={(key: string, value: any) => {
|
||||||
if (key === 'search') {
|
if (key === 'search') {
|
||||||
@@ -504,6 +795,97 @@ export default function Approved() {
|
|||||||
}}
|
}}
|
||||||
module="writer"
|
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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Review Page - Built with TablePageTemplate
|
* 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';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
@@ -284,105 +285,6 @@ export default function Review() {
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}, [toast]);
|
}, [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')
|
// Approve content - single item (changes status from 'review' to 'approved')
|
||||||
const handleApproveSingle = useCallback(async (row: Content) => {
|
const handleApproveSingle = useCallback(async (row: Content) => {
|
||||||
try {
|
try {
|
||||||
@@ -429,73 +331,23 @@ export default function Review() {
|
|||||||
}
|
}
|
||||||
}, [loadContent, toast]);
|
}, [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
|
// Bulk action handler
|
||||||
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
|
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
|
||||||
if (action === 'bulk_approve') {
|
if (action === 'bulk_approve') {
|
||||||
await handleApproveBulk(ids);
|
await handleApproveBulk(ids);
|
||||||
} else if (action === 'bulk_publish_wordpress') {
|
|
||||||
await handlePublishBulk(ids);
|
|
||||||
} else {
|
} else {
|
||||||
toast.info(`Bulk action "${action}" for ${ids.length} items`);
|
toast.info(`Bulk action "${action}" for ${ids.length} items`);
|
||||||
}
|
}
|
||||||
}, [handleApproveBulk, handlePublishBulk, toast]);
|
}, [handleApproveBulk, toast]);
|
||||||
|
|
||||||
// Row action handler
|
// Row action handler
|
||||||
const handleRowAction = useCallback(async (action: string, row: Content) => {
|
const handleRowAction = useCallback(async (action: string, row: Content) => {
|
||||||
if (action === 'approve') {
|
if (action === 'approve') {
|
||||||
await handleApproveSingle(row);
|
await handleApproveSingle(row);
|
||||||
} else if (action === 'publish_wordpress') {
|
|
||||||
await handlePublishSingle(row);
|
|
||||||
} else if (action === 'view') {
|
} else if (action === 'view') {
|
||||||
navigate(`/writer/content/${row.id}`);
|
navigate(`/writer/content/${row.id}`);
|
||||||
}
|
}
|
||||||
}, [handleApproveSingle, handlePublishSingle, navigate]);
|
}, [handleApproveSingle, navigate]);
|
||||||
|
|
||||||
// Delete handler (single)
|
// Delete handler (single)
|
||||||
const handleDelete = useCallback(async (id: string) => {
|
const handleDelete = useCallback(async (id: string) => {
|
||||||
@@ -611,10 +463,10 @@ export default function Review() {
|
|||||||
color: 'amber',
|
color: 'amber',
|
||||||
},
|
},
|
||||||
hint: totalCount > 0
|
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',
|
: 'No content pending review',
|
||||||
statusInsight: totalCount > 0
|
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.`,
|
: `No content in review. Submit drafts from Content page.`,
|
||||||
}}
|
}}
|
||||||
module="writer"
|
module="writer"
|
||||||
|
|||||||
Reference in New Issue
Block a user