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

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

View File

@@ -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"""

View 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>
);
}

View 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;

View 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;

View 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>
);
}

View 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>
);
}

View 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;

View File

@@ -294,14 +294,14 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
variant: 'primary',
},
{
key: 'publish_wordpress',
key: 'publish_site',
label: 'Publish to Site',
icon: <ArrowRightIcon className="w-5 h-5" />,
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: <CheckCircleIcon className="w-5 h-5 text-brand-500" />,
variant: 'secondary',
@@ -310,7 +310,7 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
],
bulkActions: [
{
key: 'bulk_publish_wordpress',
key: 'bulk_publish_site',
label: 'Publish to Site',
icon: <ArrowRightIcon className="w-4 h-4" />,
variant: 'success',
@@ -387,12 +387,6 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
icon: <CheckCircleIcon className="w-5 h-5" />,
variant: 'success',
},
{
key: 'publish_wordpress',
label: 'Publish to Site',
icon: <ArrowRightIcon className="w-5 h-5" />,
variant: 'primary',
},
],
bulkActions: [
{
@@ -401,12 +395,6 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
icon: <CheckCircleIcon className="w-5 h-5" />,
variant: 'success',
},
{
key: 'bulk_publish_wordpress',
label: 'Publish to Site',
icon: <ArrowRightIcon className="w-5 h-5" />,
variant: 'primary',
},
],
},
// Default config (fallback)

View File

@@ -26,6 +26,12 @@ import { useSiteStore } from '../../store/siteStore';
import { usePageSizeStore } from '../../store/pageSizeStore';
import PageHeader from '../../components/common/PageHeader';
import StandardThreeWidgetFooter from '../../components/dashboard/StandardThreeWidgetFooter';
import PublishingProgressModal, { PublishingProgressState } from '../../components/common/PublishingProgressModal';
import BulkPublishingModal, { PublishQueueItem } from '../../components/common/BulkPublishingModal';
import PublishLimitModal from '../../components/common/PublishLimitModal';
import ScheduleContentModal from '../../components/common/ScheduleContentModal';
import BulkScheduleModal from '../../components/common/BulkScheduleModal';
import BulkSchedulePreviewModal from '../../components/common/BulkSchedulePreviewModal';
export default function Approved() {
const toast = useToast();
@@ -70,6 +76,21 @@ export default function Approved() {
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [showContent, setShowContent] = useState(false);
// Publishing modals state
const [showPublishLimitModal, setShowPublishLimitModal] = useState(false);
const [showSinglePublishModal, setShowSinglePublishModal] = useState(false);
const [showBulkPublishModal, setShowBulkPublishModal] = useState(false);
const [singlePublishState, setSinglePublishState] = useState<PublishingProgressState | null>(null);
const [bulkPublishQueue, setBulkPublishQueue] = useState<PublishQueueItem[]>([]);
// Scheduling modals state
const [showScheduleModal, setShowScheduleModal] = useState(false);
const [showBulkScheduleModal, setShowBulkScheduleModal] = useState(false);
const [showBulkSchedulePreviewModal, setShowBulkSchedulePreviewModal] = useState(false);
const [scheduleContent, setScheduleContent] = useState<Content | null>(null);
const [bulkScheduleItems, setBulkScheduleItems] = useState<Content[]>([]);
const [bulkSchedulePreview, setBulkSchedulePreview] = useState<any>(null);
// Load dynamic filter options based on current site's data and applied filters
// This implements cascading filters - each filter's options reflect what's available
// given the other currently applied filters
@@ -226,33 +247,240 @@ export default function Approved() {
setCurrentPage(1);
};
// Handle single content publish with progress modal
const handleSinglePublish = useCallback(async (row: Content) => {
if (!activeSite) {
toast.error('No active site selected');
return;
}
// Initialize publishing state
const initialState: PublishingProgressState = {
contentId: row.id,
contentTitle: row.title,
destination: activeSite.platform_type || 'wordpress',
siteName: activeSite.name,
status: 'preparing',
progress: 0,
statusMessage: 'Preparing content...',
error: null,
externalUrl: null,
externalId: null,
};
setSinglePublishState(initialState);
setShowSinglePublishModal(true);
try {
// Phase 1: Preparing (0-25%)
await new Promise(resolve => setTimeout(resolve, 500));
setSinglePublishState(prev => prev ? { ...prev, progress: 25, statusMessage: 'Uploading to site...' } : null);
// Phase 2: Uploading (25-50%) - Make API call
const response = await fetchAPI('/v1/publisher/publish/', {
method: 'POST',
body: JSON.stringify({
content_id: row.id,
destinations: [activeSite.platform_type || 'wordpress']
})
});
if (response.success && response.data?.results?.[0]?.success) {
const result = response.data.results[0];
// Phase 3: Processing (50-75%)
setSinglePublishState(prev => prev ? { ...prev, progress: 50, statusMessage: 'Processing response...' } : null);
await new Promise(resolve => setTimeout(resolve, 300));
// Phase 4: Finalizing (75-100%)
setSinglePublishState(prev => prev ? { ...prev, progress: 75, statusMessage: 'Finalizing...' } : null);
await new Promise(resolve => setTimeout(resolve, 300));
// Complete
setSinglePublishState(prev => prev ? {
...prev,
status: 'completed',
progress: 100,
statusMessage: 'Published successfully!',
externalUrl: result.url,
externalId: result.external_id,
} : null);
loadContent();
} else {
throw new Error(response.error || 'Failed to publish');
}
} catch (error: any) {
console.error('Publish error:', error);
setSinglePublishState(prev => prev ? {
...prev,
status: 'failed',
progress: 0,
statusMessage: 'Failed to publish',
error: error.message || 'Network error',
} : null);
}
}, [activeSite, toast, loadContent]);
// Schedule single content
const handleScheduleContent = useCallback(async (contentId: number, scheduledDate: string) => {
try {
await fetchAPI(`/v1/writer/content/${contentId}/schedule/`, {
method: 'POST',
body: JSON.stringify({ scheduled_publish_at: scheduledDate }),
});
toast.success('Content scheduled successfully');
loadContent();
} catch (error: any) {
toast.error(`Failed to schedule: ${error.message}`);
throw error;
}
}, [toast, loadContent]);
// Reschedule content (same API endpoint)
const handleRescheduleContent = useCallback(async (contentId: number, scheduledDate: string) => {
try {
await fetchAPI(`/v1/writer/content/${contentId}/reschedule/`, {
method: 'POST',
body: JSON.stringify({ scheduled_at: scheduledDate }),
});
toast.success('Content rescheduled successfully');
loadContent();
} catch (error: any) {
toast.error(`Failed to reschedule: ${error.message}`);
throw error;
}
}, [toast, loadContent]);
// Unschedule content
const handleUnscheduleContent = useCallback(async (contentId: number) => {
try {
await fetchAPI(`/v1/writer/content/${contentId}/unschedule/`, {
method: 'POST',
});
toast.success('Content unscheduled successfully');
loadContent();
} catch (error: any) {
toast.error(`Failed to unschedule: ${error.message}`);
}
}, [toast, loadContent]);
// Bulk schedule with manual date/time
const handleBulkScheduleManual = useCallback(async (contentIds: number[], scheduledDate: string) => {
try {
let successCount = 0;
let failedCount = 0;
for (const contentId of contentIds) {
try {
await fetchAPI(`/v1/writer/content/${contentId}/schedule/`, {
method: 'POST',
body: JSON.stringify({ scheduled_publish_at: scheduledDate }),
});
successCount++;
} catch (error) {
console.error(`Failed to schedule content ${contentId}:`, error);
failedCount++;
}
}
if (successCount > 0) {
toast.success(`Scheduled ${successCount} item(s)`);
}
if (failedCount > 0) {
toast.warning(`${failedCount} item(s) failed to schedule`);
}
loadContent();
} catch (error: any) {
toast.error(`Failed to schedule: ${error.message}`);
throw error;
}
}, [toast, loadContent]);
// Bulk schedule with site defaults - show preview first
const handleBulkScheduleWithDefaults = useCallback(async () => {
if (!activeSite || selectedIds.length === 0) return;
try {
const contentIds = selectedIds.map(id => parseInt(id));
const response = await fetchAPI('/v1/writer/content/bulk_schedule_preview/', {
method: 'POST',
body: JSON.stringify({
content_ids: contentIds,
site_id: activeSite.id
})
});
if (response.success) {
setBulkSchedulePreview(response.data);
setShowBulkSchedulePreviewModal(true);
} else {
throw new Error(response.error || 'Failed to generate preview');
}
} catch (error: any) {
toast.error(`Failed to generate schedule preview: ${error.message}`);
}
}, [activeSite, selectedIds, toast]);
// Confirm bulk schedule with site defaults
const handleConfirmBulkSchedule = useCallback(async () => {
if (!activeSite || selectedIds.length === 0) return;
try {
const contentIds = selectedIds.map(id => parseInt(id));
const response = await fetchAPI('/v1/writer/content/bulk_schedule/', {
method: 'POST',
body: JSON.stringify({
content_ids: contentIds,
use_site_defaults: true,
site_id: activeSite.id
})
});
if (response.success) {
toast.success(`Scheduled ${response.data.scheduled_count} item(s)`);
setShowBulkSchedulePreviewModal(false);
setBulkSchedulePreview(null);
setSelectedIds([]);
loadContent();
} else {
throw new Error(response.error || 'Failed to schedule');
}
} catch (error: any) {
toast.error(`Failed to schedule: ${error.message}`);
}
}, [activeSite, selectedIds, toast, loadContent]);
// Open site settings in new tab
const handleOpenSiteSettings = useCallback(() => {
if (activeSite) {
window.open(`/sites/${activeSite.id}/settings?tab=publishing`, '_blank');
}
}, [activeSite]);
// Row action handler
const handleRowAction = useCallback(async (action: string, row: Content) => {
if (action === 'publish_wordpress') {
try {
const response = await fetchAPI('/v1/publisher/publish/', {
method: 'POST',
body: JSON.stringify({
content_id: row.id,
destinations: ['wordpress']
})
});
if (response.success) {
toast.success(`Published "${row.title}" to WordPress`);
loadContent();
} else {
toast.error(response.error || 'Failed to publish');
}
} catch (error: any) {
console.error('WordPress publish error:', error);
toast.error(`Failed to publish: ${error.message || 'Network error'}`);
}
} else if (action === 'view_on_wordpress') {
if (action === 'publish_site') {
await handleSinglePublish(row);
} else if (action === 'view_on_site') {
if (row.external_url) {
window.open(row.external_url, '_blank');
} else {
toast.warning('WordPress URL not available');
toast.warning('Site URL not available');
}
} else if (action === 'schedule') {
setScheduleContent(row);
setShowScheduleModal(true);
} else if (action === 'reschedule') {
setScheduleContent(row);
setShowScheduleModal(true);
} else if (action === 'unschedule') {
if (window.confirm(`Are you sure you want to unschedule "${row.title}"?`)) {
await handleUnscheduleContent(row.id);
}
} else if (action === 'edit') {
// Navigate to content editor
@@ -263,7 +491,7 @@ export default function Approved() {
toast.warning('Unable to edit: Site information not available');
}
}
}, [toast, loadContent, navigate]);
}, [toast, navigate, handleSinglePublish, handleUnscheduleContent]);
const handleDelete = useCallback(async (id: number) => {
await deleteContent(id);
@@ -276,56 +504,113 @@ export default function Approved() {
return result;
}, [loadContent]);
// Bulk WordPress publish
const handleBulkPublishWordPress = useCallback(async (ids: string[]) => {
try {
const contentIds = ids.map(id => parseInt(id));
let successCount = 0;
let failedCount = 0;
// Handle bulk publish with progress modal and limit validation
const handleBulkPublishToSite = useCallback(async (ids: string[]) => {
if (!activeSite) {
toast.error('No active site selected');
return;
}
// Validate: Max 5 items for direct bulk publish
if (ids.length > 5) {
setShowPublishLimitModal(true);
return;
}
try {
// Initialize queue
const queue: PublishQueueItem[] = ids.map((id, index) => {
const contentItem = content.find(c => c.id === parseInt(id));
return {
contentId: parseInt(id),
contentTitle: contentItem?.title || `Content #${id}`,
index: index + 1,
status: 'pending' as const,
progress: 0,
statusMessage: 'Pending',
error: null,
externalUrl: null,
externalId: null,
};
});
setBulkPublishQueue(queue);
setShowBulkPublishModal(true);
// Process sequentially
for (let i = 0; i < queue.length; i++) {
// Update status to processing
setBulkPublishQueue(prev => prev.map((item, idx) =>
idx === i ? { ...item, status: 'processing', progress: 0, statusMessage: 'Preparing...' } : item
));
// Publish each item individually
for (const contentId of contentIds) {
try {
// Simulate progress animation
await new Promise(resolve => setTimeout(resolve, 300));
setBulkPublishQueue(prev => prev.map((item, idx) =>
idx === i ? { ...item, progress: 25, statusMessage: 'Uploading to site...' } : item
));
// Call API
const response = await fetchAPI('/v1/publisher/publish/', {
method: 'POST',
body: JSON.stringify({
content_id: contentId,
destinations: ['wordpress']
content_id: queue[i].contentId,
destinations: [activeSite.platform_type || 'wordpress']
})
});
if (response.success) {
successCount++;
// Handle response
if (response.success && response.data?.results?.[0]?.success) {
const result = response.data.results[0];
// Animate to completion
setBulkPublishQueue(prev => prev.map((item, idx) =>
idx === i ? { ...item, progress: 75, statusMessage: 'Finalizing...' } : item
));
await new Promise(resolve => setTimeout(resolve, 200));
setBulkPublishQueue(prev => prev.map((item, idx) =>
idx === i ? {
...item,
status: 'completed',
progress: 100,
statusMessage: 'Published',
externalUrl: result.url,
externalId: result.external_id,
} : item
));
} else {
failedCount++;
console.warn(`Failed to publish content ${contentId}:`, response.error);
throw new Error(response.error || 'Unknown error');
}
} catch (error) {
failedCount++;
console.error(`Error publishing content ${contentId}:`, error);
} catch (error: any) {
console.error(`Error publishing content ${queue[i].contentId}:`, error);
setBulkPublishQueue(prev => prev.map((item, idx) =>
idx === i ? {
...item,
status: 'failed',
progress: 0,
statusMessage: 'Failed',
error: error.message || 'Network error',
} : item
));
}
}
if (successCount > 0) {
toast.success(`Published ${successCount} item(s) to WordPress`);
}
if (failedCount > 0) {
toast.warning(`${failedCount} item(s) failed to publish`);
}
// Refresh content after all done
loadContent();
} catch (error: any) {
toast.error(`Failed to bulk publish: ${error.message}`);
throw error;
}
}, [toast, loadContent]);
}, [activeSite, content, toast, loadContent]);
// Bulk action handler
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
if (action === 'bulk_publish_wordpress') {
await handleBulkPublishWordPress(ids);
if (action === 'bulk_publish_site') {
await handleBulkPublishToSite(ids);
}
}, [handleBulkPublishWordPress]);
}, [handleBulkPublishToSite]);
// Bulk status update handler
const handleBulkUpdateStatus = useCallback(async (ids: string[], status: string) => {
@@ -430,10 +715,16 @@ export default function Approved() {
content_structure: contentStructureFilter,
}}
primaryAction={{
label: 'Publish to Site',
label: selectedIds.length > 5 ? 'Publish (Limit Exceeded)' : 'Publish to Site',
icon: <BoltIcon className="w-4 h-4" />,
onClick: () => handleBulkAction('bulk_publish_wordpress', selectedIds),
onClick: () => handleBulkAction('bulk_publish_site', selectedIds),
variant: 'success',
disabled: selectedIds.length === 0,
tooltip: selectedIds.length > 5
? 'You can only publish 5 items at once. Use scheduling for more.'
: selectedIds.length > 0
? `Publish ${selectedIds.length} item${selectedIds.length !== 1 ? 's' : ''} to ${activeSite?.name || 'site'}`
: 'Select items to publish',
}}
onFilterChange={(key: string, value: any) => {
if (key === 'search') {
@@ -504,6 +795,97 @@ export default function Approved() {
}}
module="writer"
/>
{/* Publishing Modals */}
{singlePublishState && (
<PublishingProgressModal
isOpen={showSinglePublishModal}
onClose={() => setShowSinglePublishModal(false)}
publishingState={singlePublishState}
onRetry={() => {
setShowSinglePublishModal(false);
const contentItem = content.find(c => c.id === singlePublishState.contentId);
if (contentItem) {
handleSinglePublish(contentItem);
}
}}
/>
)}
<BulkPublishingModal
isOpen={showBulkPublishModal}
onClose={() => {
setShowBulkPublishModal(false);
setBulkPublishQueue([]);
setSelectedIds([]);
}}
queue={bulkPublishQueue}
siteName={activeSite?.name || 'Site'}
destination={activeSite?.platform_type || 'wordpress'}
onUpdateQueue={setBulkPublishQueue}
onRetryItem={(contentId) => {
const contentItem = content.find(c => c.id === contentId);
if (contentItem) {
handleSinglePublish(contentItem);
}
}}
/>
<PublishLimitModal
isOpen={showPublishLimitModal}
onClose={() => setShowPublishLimitModal(false)}
selectedCount={selectedIds.length}
onScheduleInstead={() => {
setShowPublishLimitModal(false);
handleBulkScheduleWithDefaults();
}}
/>
<ScheduleContentModal
isOpen={showScheduleModal}
onClose={() => {
setShowScheduleModal(false);
setScheduleContent(null);
}}
content={scheduleContent}
onSchedule={async (contentId, scheduledDate) => {
if (scheduleContent?.site_status === 'scheduled' || scheduleContent?.site_status === 'failed') {
await handleRescheduleContent(contentId, scheduledDate);
} else {
await handleScheduleContent(contentId, scheduledDate);
}
setShowScheduleModal(false);
setScheduleContent(null);
}}
mode={scheduleContent?.site_status === 'scheduled' || scheduleContent?.site_status === 'failed' ? 'reschedule' : 'schedule'}
/>
<BulkScheduleModal
isOpen={showBulkScheduleModal}
onClose={() => {
setShowBulkScheduleModal(false);
setBulkScheduleItems([]);
}}
contentItems={bulkScheduleItems}
onSchedule={async (contentIds, scheduledDate) => {
await handleBulkScheduleManual(contentIds, scheduledDate);
setShowBulkScheduleModal(false);
setBulkScheduleItems([]);
setSelectedIds([]);
}}
/>
<BulkSchedulePreviewModal
isOpen={showBulkSchedulePreviewModal}
onClose={() => {
setShowBulkSchedulePreviewModal(false);
setBulkSchedulePreview(null);
}}
previewData={bulkSchedulePreview}
onConfirm={handleConfirmBulkSchedule}
onChangeSettings={handleOpenSiteSettings}
siteId={activeSite?.id || 0}
/>
</>
);
}

View File

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