phase 4-6 - with buggy contetn calendar page
This commit is contained in:
219
frontend/src/components/common/ErrorDetailsModal.tsx
Normal file
219
frontend/src/components/common/ErrorDetailsModal.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import React from 'react';
|
||||
import { Modal } from '../ui/modal';
|
||||
import Button from '../ui/button/Button';
|
||||
import { ErrorIcon, CalendarIcon, BoltIcon, ExternalLinkIcon } from '../../icons';
|
||||
|
||||
interface Content {
|
||||
id: number;
|
||||
title: string;
|
||||
site_status?: string;
|
||||
scheduled_publish_at?: string | null;
|
||||
site_status_updated_at?: string | null;
|
||||
site_status_message?: string | null;
|
||||
}
|
||||
|
||||
interface Site {
|
||||
id: number;
|
||||
name: string;
|
||||
platform_type: string;
|
||||
}
|
||||
|
||||
interface ErrorDetailsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
content: Content | null;
|
||||
site: Site | null;
|
||||
onPublishNow: () => void;
|
||||
onReschedule: () => void;
|
||||
onFixSettings: () => void;
|
||||
}
|
||||
|
||||
const ErrorDetailsModal: React.FC<ErrorDetailsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
content,
|
||||
site,
|
||||
onPublishNow,
|
||||
onReschedule,
|
||||
onFixSettings
|
||||
}) => {
|
||||
if (!content || !site) return null;
|
||||
|
||||
const formatDate = (isoString: string | null) => {
|
||||
if (!isoString) return 'N/A';
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
} catch (error) {
|
||||
return isoString;
|
||||
}
|
||||
};
|
||||
|
||||
const errorMessage = content.site_status_message || 'Publishing failed with no error message.';
|
||||
|
||||
// Parse error message to provide helpful suggestions
|
||||
const getErrorSuggestion = (error: string) => {
|
||||
const lowerError = error.toLowerCase();
|
||||
|
||||
if (lowerError.includes('credential') || lowerError.includes('authentication') || lowerError.includes('403')) {
|
||||
return 'The publishing site returned an authentication error. Please check the API key in Site Settings.';
|
||||
} else if (lowerError.includes('timeout') || lowerError.includes('network')) {
|
||||
return 'The publishing site did not respond in time. Please check your internet connection and site availability.';
|
||||
} else if (lowerError.includes('404') || lowerError.includes('not found')) {
|
||||
return 'The publishing endpoint was not found. Please verify the site URL in Site Settings.';
|
||||
} else if (lowerError.includes('500') || lowerError.includes('server error')) {
|
||||
return 'The publishing site returned a server error. Please try again later or contact site support.';
|
||||
} else if (lowerError.includes('required field') || lowerError.includes('missing')) {
|
||||
return 'Required fields are missing in the content. Please review and complete all necessary fields.';
|
||||
} else if (lowerError.includes('rate limit')) {
|
||||
return 'Too many requests were sent to the publishing site. Please wait a few minutes and try again.';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const suggestion = getErrorSuggestion(errorMessage);
|
||||
const platformName = site.platform_type.charAt(0).toUpperCase() + site.platform_type.slice(1);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
showCloseButton={true}
|
||||
>
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<ErrorIcon className="w-8 h-8 text-error-500" />
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Publishing Error Details
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Content failed to publish
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Details */}
|
||||
<div className="space-y-3 mb-6">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">Content:</p>
|
||||
<p className="text-sm text-gray-900 mt-1">"{content.title}"</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">Site:</p>
|
||||
<p className="text-sm text-gray-900 mt-1">
|
||||
{site.name} ({platformName})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{content.scheduled_publish_at && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">Scheduled:</p>
|
||||
<p className="text-sm text-gray-900 mt-1">
|
||||
{formatDate(content.scheduled_publish_at)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{content.site_status_updated_at && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700">Failed:</p>
|
||||
<p className="text-sm text-gray-900 mt-1">
|
||||
{formatDate(content.site_status_updated_at)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
<div className="mb-6">
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Error Message:</p>
|
||||
<div className="bg-error-50 border border-error-200 rounded-lg p-4">
|
||||
<p className="text-sm text-error-900 whitespace-pre-wrap break-words">
|
||||
{errorMessage}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestion */}
|
||||
{suggestion && (
|
||||
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 mb-6">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-blue-900">Suggestion:</p>
|
||||
<p className="text-sm text-blue-800 mt-1">{suggestion}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-gray-700">Actions:</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onFixSettings();
|
||||
onClose();
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<ExternalLinkIcon className="w-4 h-4 mr-2" />
|
||||
Fix Site Settings
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onPublishNow();
|
||||
onClose();
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<BoltIcon className="w-4 h-4 mr-2" />
|
||||
Publish Now
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onReschedule();
|
||||
onClose();
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<CalendarIcon className="w-4 h-4 mr-2" />
|
||||
Reschedule
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="w-full"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorDetailsModal;
|
||||
@@ -298,7 +298,35 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
|
||||
label: 'Publish to Site',
|
||||
icon: <ArrowRightIcon className="w-5 h-5" />,
|
||||
variant: 'success',
|
||||
shouldShow: (row: any) => !row.external_id, // Only show if not published
|
||||
shouldShow: (row: any) => !row.external_id && row.site_status !== 'scheduled' && row.site_status !== 'publishing', // Only show if not published and not scheduled
|
||||
},
|
||||
{
|
||||
key: 'schedule',
|
||||
label: 'Schedule',
|
||||
icon: <BoltIcon className="w-5 h-5" />,
|
||||
variant: 'primary',
|
||||
shouldShow: (row: any) => !row.external_id && row.site_status !== 'scheduled' && row.site_status !== 'publishing', // Only show if not published and not scheduled
|
||||
},
|
||||
{
|
||||
key: 'reschedule',
|
||||
label: 'Reschedule',
|
||||
icon: <BoltIcon className="w-5 h-5" />,
|
||||
variant: 'secondary',
|
||||
shouldShow: (row: any) => row.site_status === 'scheduled', // Only show for scheduled items
|
||||
},
|
||||
{
|
||||
key: 'unschedule',
|
||||
label: 'Unschedule',
|
||||
icon: <TrashBinIcon className="w-5 h-5" />,
|
||||
variant: 'danger',
|
||||
shouldShow: (row: any) => row.site_status === 'scheduled', // Only show for scheduled items
|
||||
},
|
||||
{
|
||||
key: 'view_error',
|
||||
label: 'View Error Details',
|
||||
icon: <CheckCircleIcon className="w-5 h-5 text-danger-500" />,
|
||||
variant: 'danger',
|
||||
shouldShow: (row: any) => row.site_status === 'failed', // Only show for failed items
|
||||
},
|
||||
{
|
||||
key: 'view_on_site',
|
||||
@@ -315,6 +343,18 @@ const tableActionsConfigs: Record<string, TableActionsConfig> = {
|
||||
icon: <ArrowRightIcon className="w-4 h-4" />,
|
||||
variant: 'success',
|
||||
},
|
||||
{
|
||||
key: 'bulk_schedule_manual',
|
||||
label: 'Schedule (Manual)',
|
||||
icon: <BoltIcon className="w-4 h-4" />,
|
||||
variant: 'primary',
|
||||
},
|
||||
{
|
||||
key: 'bulk_schedule_defaults',
|
||||
label: 'Schedule (Site Defaults)',
|
||||
icon: <BoltIcon className="w-4 h-4" />,
|
||||
variant: 'primary',
|
||||
},
|
||||
{
|
||||
key: 'export',
|
||||
label: 'Export Selected',
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||
import { CalendarItemTooltip } from '../../components/ui/tooltip';
|
||||
import { useSiteStore } from '../../store/siteStore';
|
||||
import { fetchContent, Content, fetchAPI } from '../../services/api';
|
||||
import ScheduleContentModal from '../../components/common/ScheduleContentModal';
|
||||
import {
|
||||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
@@ -67,10 +68,15 @@ export default function ContentCalendar() {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('calendar'); // Default to calendar view
|
||||
const [draggedItem, setDraggedItem] = useState<Content | null>(null);
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date()); // Track current month for calendar
|
||||
|
||||
// Schedule modal state
|
||||
const [showScheduleModal, setShowScheduleModal] = useState(false);
|
||||
const [scheduleContent, setScheduleContent] = useState<Content | null>(null);
|
||||
const [isRescheduling, setIsRescheduling] = useState(false);
|
||||
|
||||
// Derived state: Queue items (scheduled or publishing - exclude already published)
|
||||
const queueItems = useMemo(() => {
|
||||
return allContent
|
||||
const items = allContent
|
||||
.filter((c: Content) =>
|
||||
(c.site_status === 'scheduled' || c.site_status === 'publishing') &&
|
||||
(!c.external_id || c.external_id === '') // Exclude already published items
|
||||
@@ -80,6 +86,13 @@ export default function ContentCalendar() {
|
||||
const dateB = b.scheduled_publish_at ? new Date(b.scheduled_publish_at).getTime() : 0;
|
||||
return dateA - dateB;
|
||||
});
|
||||
|
||||
console.log('[ContentCalendar] queueItems (derived):', items.length, 'items');
|
||||
items.forEach(item => {
|
||||
console.log(' Queue item:', item.id, item.title, 'scheduled:', item.scheduled_publish_at);
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [allContent]);
|
||||
|
||||
// Derived state: Published items (have external_id - same logic as Content Approved page)
|
||||
@@ -98,12 +111,40 @@ export default function ContentCalendar() {
|
||||
);
|
||||
}, [allContent]);
|
||||
|
||||
// Derived state: Failed items (publish failures)
|
||||
const failedItems = useMemo(() => {
|
||||
const items = allContent
|
||||
.filter((c: Content) => c.site_status === 'failed')
|
||||
.sort((a: Content, b: Content) => {
|
||||
// Sort by failure time (most recent first)
|
||||
const dateA = a.site_status_updated_at ? new Date(a.site_status_updated_at).getTime() : 0;
|
||||
const dateB = b.site_status_updated_at ? new Date(b.site_status_updated_at).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
console.log('[ContentCalendar] failedItems (derived):', items.length, 'items');
|
||||
items.forEach(item => {
|
||||
console.log(' Failed item:', item.id, item.title, 'error:', item.site_status_message);
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [allContent]);
|
||||
|
||||
// Calculate stats from allContent
|
||||
const stats = useMemo(() => {
|
||||
const now = new Date();
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// DEBUG: Check scheduled items in stats calculation
|
||||
const scheduledItems = allContent.filter((c: Content) =>
|
||||
c.site_status === 'scheduled' && (!c.external_id || c.external_id === '')
|
||||
);
|
||||
console.log('[ContentCalendar] STATS CALCULATION - Scheduled items:', scheduledItems.length);
|
||||
scheduledItems.forEach(c => {
|
||||
console.log(' Stats scheduled item:', c.id, c.title, 'external_id:', c.external_id);
|
||||
});
|
||||
|
||||
// Published in last 30 days - check EITHER external_id OR site_status='published'
|
||||
const publishedLast30Days = allContent.filter((c: Content) => {
|
||||
const isPublished = (c.external_id && c.external_id !== '') || c.site_status === 'published';
|
||||
@@ -124,14 +165,13 @@ export default function ContentCalendar() {
|
||||
|
||||
return {
|
||||
// Scheduled count excludes items that are already published
|
||||
scheduled: allContent.filter((c: Content) =>
|
||||
c.site_status === 'scheduled' && (!c.external_id || c.external_id === '') && c.site_status !== 'published'
|
||||
).length,
|
||||
scheduled: scheduledItems.length,
|
||||
publishing: allContent.filter((c: Content) => c.site_status === 'publishing').length,
|
||||
// Published: check EITHER external_id OR site_status='published'
|
||||
published: allContent.filter((c: Content) =>
|
||||
(c.external_id && c.external_id !== '') || c.site_status === 'published'
|
||||
).length,
|
||||
failed: allContent.filter((c: Content) => c.site_status === 'failed').length,
|
||||
review: allContent.filter((c: Content) => c.status === 'review').length,
|
||||
approved: allContent.filter((c: Content) =>
|
||||
c.status === 'approved' && (!c.external_id || c.external_id === '') && c.site_status !== 'published'
|
||||
@@ -142,32 +182,99 @@ export default function ContentCalendar() {
|
||||
}, [allContent]);
|
||||
|
||||
const loadQueue = useCallback(async () => {
|
||||
if (!activeSite?.id) return;
|
||||
if (!activeSite?.id) {
|
||||
console.log('[ContentCalendar] No active site selected, skipping load');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch all content for this site
|
||||
const response = await fetchContent({
|
||||
site_id: activeSite.id,
|
||||
page_size: 200,
|
||||
// IMPORTANT: Since content is ordered by -created_at, we need to fetch items by specific site_status
|
||||
// Otherwise old scheduled/failed items will be on later pages and won't load
|
||||
|
||||
console.log('[ContentCalendar] ========== SITE FILTERING DEBUG ==========');
|
||||
console.log('[ContentCalendar] Active site ID:', activeSite.id);
|
||||
console.log('[ContentCalendar] Active site name:', activeSite.name);
|
||||
console.log('[ContentCalendar] Fetching content with multiple targeted queries...');
|
||||
|
||||
// Fetch scheduled items (all of them, regardless of page)
|
||||
const scheduledResponse = await fetchAPI('/v1/writer/content/', {
|
||||
params: {
|
||||
site_id: activeSite.id,
|
||||
page_size: 1000,
|
||||
site_status: 'scheduled', // Filter specifically for scheduled
|
||||
}
|
||||
});
|
||||
|
||||
// Debug: Log content with external_id (published status)
|
||||
console.log('[ContentCalendar] Total content items:', response.results?.length);
|
||||
console.log('[ContentCalendar] Published items (with external_id):', response.results?.filter(c => c.external_id && c.external_id !== '').length);
|
||||
console.log('[ContentCalendar] Scheduled items:', response.results?.filter(c => c.site_status === 'scheduled').length);
|
||||
console.log('[ContentCalendar] Sample content:', response.results?.slice(0, 3).map(c => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
status: c.status,
|
||||
site_status: c.site_status,
|
||||
external_id: c.external_id,
|
||||
scheduled_publish_at: c.scheduled_publish_at,
|
||||
updated_at: c.updated_at
|
||||
})));
|
||||
// Fetch failed items (all of them)
|
||||
const failedResponse = await fetchAPI('/v1/writer/content/', {
|
||||
params: {
|
||||
site_id: activeSite.id,
|
||||
page_size: 1000,
|
||||
site_status: 'failed', // Filter specifically for failed
|
||||
}
|
||||
});
|
||||
|
||||
setAllContent(response.results || []);
|
||||
// Fetch approved items (for sidebar drag-drop)
|
||||
const approvedResponse = await fetchAPI('/v1/writer/content/', {
|
||||
params: {
|
||||
site_id: activeSite.id,
|
||||
page_size: 100,
|
||||
status: 'approved', // Approved workflow status
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch published items (with external_id) for display
|
||||
const publishedResponse = await fetchAPI('/v1/writer/content/', {
|
||||
params: {
|
||||
site_id: activeSite.id,
|
||||
page_size: 100,
|
||||
ordering: '-updated_at', // Most recently published first
|
||||
}
|
||||
});
|
||||
|
||||
// Combine all results, removing duplicates by ID
|
||||
const allItems = [
|
||||
...(scheduledResponse.results || []),
|
||||
...(failedResponse.results || []),
|
||||
...(approvedResponse.results || []),
|
||||
...(publishedResponse.results || []),
|
||||
];
|
||||
|
||||
// Remove duplicates by ID
|
||||
const uniqueItems = Array.from(
|
||||
new Map(allItems.map(item => [item.id, item])).values()
|
||||
);
|
||||
|
||||
// Debug: Comprehensive logging
|
||||
console.log('[ContentCalendar] ========== DATA LOAD DEBUG ==========');
|
||||
console.log('[ContentCalendar] Scheduled query returned:', scheduledResponse.results?.length, 'items');
|
||||
console.log('[ContentCalendar] Failed query returned:', failedResponse.results?.length, 'items');
|
||||
console.log('[ContentCalendar] Approved query returned:', approvedResponse.results?.length, 'items');
|
||||
console.log('[ContentCalendar] Published query returned:', publishedResponse.results?.length, 'items');
|
||||
console.log('[ContentCalendar] Total unique items after deduplication:', uniqueItems.length);
|
||||
|
||||
console.log('[ContentCalendar] ALL SCHEDULED ITEMS DETAILS:');
|
||||
scheduledResponse.results?.forEach(c => {
|
||||
console.log(' - ID:', c.id, '| Title:', c.title);
|
||||
console.log(' status:', c.status, '| site_status:', c.site_status);
|
||||
console.log(' scheduled_publish_at:', c.scheduled_publish_at);
|
||||
console.log(' external_id:', c.external_id);
|
||||
console.log(' ---');
|
||||
});
|
||||
|
||||
console.log('[ContentCalendar] ALL FAILED ITEMS DETAILS:');
|
||||
failedResponse.results?.forEach(c => {
|
||||
console.log(' - ID:', c.id, '| Title:', c.title);
|
||||
console.log(' status:', c.status, '| site_status:', c.site_status);
|
||||
console.log(' site_status_message:', c.site_status_message);
|
||||
console.log(' scheduled_publish_at:', c.scheduled_publish_at);
|
||||
console.log(' ---');
|
||||
});
|
||||
console.log('[ContentCalendar] ====================================');
|
||||
|
||||
setAllContent(uniqueItems);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load content: ${error.message}`);
|
||||
} finally {
|
||||
@@ -175,11 +282,54 @@ export default function ContentCalendar() {
|
||||
}
|
||||
}, [activeSite?.id, toast]);
|
||||
|
||||
// Load queue when active site changes
|
||||
useEffect(() => {
|
||||
if (activeSite?.id) {
|
||||
console.log('[ContentCalendar] Site changed to:', activeSite.id, activeSite.name);
|
||||
console.log('[ContentCalendar] Triggering loadQueue...');
|
||||
loadQueue();
|
||||
} else {
|
||||
console.log('[ContentCalendar] No active site, clearing content');
|
||||
setAllContent([]);
|
||||
}
|
||||
}, [activeSite?.id]); // Only depend on activeSite.id, loadQueue is stable
|
||||
|
||||
// Reschedule content
|
||||
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('Rescheduled successfully');
|
||||
loadQueue(); // Reload calendar
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to reschedule: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}, [toast, loadQueue]);
|
||||
|
||||
// Open reschedule modal
|
||||
const openRescheduleModal = useCallback((item: Content) => {
|
||||
setScheduleContent(item);
|
||||
setIsRescheduling(true);
|
||||
setShowScheduleModal(true);
|
||||
}, []);
|
||||
|
||||
// Handle schedule/reschedule from modal
|
||||
const handleScheduleFromModal = useCallback(async (contentId: number, scheduledDate: string) => {
|
||||
if (isRescheduling) {
|
||||
await handleRescheduleContent(contentId, scheduledDate);
|
||||
} else {
|
||||
const response = await scheduleContent(contentId, scheduledDate);
|
||||
toast.success('Scheduled successfully');
|
||||
loadQueue();
|
||||
}
|
||||
}, [activeSite?.id]); // Removed loadQueue from dependencies to prevent reload loops
|
||||
setShowScheduleModal(false);
|
||||
setScheduleContent(null);
|
||||
setIsRescheduling(false);
|
||||
}, [isRescheduling, handleRescheduleContent, toast, loadQueue]);
|
||||
|
||||
// Drag and drop handlers for list view
|
||||
const handleDragStart = (e: React.DragEvent, item: Content, source: 'queue' | 'approved') => {
|
||||
@@ -589,6 +739,14 @@ export default function ContentCalendar() {
|
||||
</div>
|
||||
{getStatusBadge(item)}
|
||||
<div className="flex items-center gap-1">
|
||||
<IconButton
|
||||
icon={<PencilIcon className="w-4 h-4" />}
|
||||
variant="ghost"
|
||||
tone="neutral"
|
||||
size="sm"
|
||||
onClick={() => openRescheduleModal(item)}
|
||||
title="Edit schedule"
|
||||
/>
|
||||
<IconButton
|
||||
icon={<EyeIcon className="w-4 h-4" />}
|
||||
variant="ghost"
|
||||
@@ -753,8 +911,9 @@ export default function ContentCalendar() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Approved Content Sidebar - reduced width by 15% (80 -> 68) */}
|
||||
<div className="w-68 flex-shrink-0">
|
||||
{/* Right Sidebar - Contains Approved and Failed Items */}
|
||||
<div className="w-68 flex-shrink-0 space-y-4">
|
||||
{/* Approved Content Card */}
|
||||
<ComponentCard title="Approved Content" desc="Drag to publishing queue to schedule">
|
||||
<div className="space-y-2 max-h-[600px] overflow-y-auto">
|
||||
{approvedItems.length === 0 ? (
|
||||
@@ -797,8 +956,77 @@ export default function ContentCalendar() {
|
||||
)}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
{/* Failed Items Card - Show below Approved if any exist */}
|
||||
{failedItems.length > 0 && (
|
||||
<ComponentCard
|
||||
title="Failed Publishes"
|
||||
desc="Items that failed to publish. Review and retry."
|
||||
headerContent={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate('/writer/approved?site_status=failed')}
|
||||
>
|
||||
View All
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||
{failedItems.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800"
|
||||
>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm truncate">
|
||||
{item.title}
|
||||
</h4>
|
||||
<div className="mt-2 space-y-1">
|
||||
{item.site_status_message && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 line-clamp-2">
|
||||
{item.site_status_message}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1 pt-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={() => openRescheduleModal(item)}
|
||||
startIcon={<CalendarIcon className="w-3 h-3" />}
|
||||
>
|
||||
Reschedule
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => navigate(`/writer/content/${item.id}`)}
|
||||
>
|
||||
View Details
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schedule/Reschedule Modal */}
|
||||
{showScheduleModal && scheduleContent && (
|
||||
<ScheduleContentModal
|
||||
isOpen={showScheduleModal}
|
||||
onClose={() => {
|
||||
setShowScheduleModal(false);
|
||||
setScheduleContent(null);
|
||||
setIsRescheduling(false);
|
||||
}}
|
||||
content={scheduleContent}
|
||||
onSchedule={handleScheduleFromModal}
|
||||
mode={isRescheduling ? 'reschedule' : 'schedule'}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import PublishLimitModal from '../../components/common/PublishLimitModal';
|
||||
import ScheduleContentModal from '../../components/common/ScheduleContentModal';
|
||||
import BulkScheduleModal from '../../components/common/BulkScheduleModal';
|
||||
import BulkSchedulePreviewModal from '../../components/common/BulkSchedulePreviewModal';
|
||||
import ErrorDetailsModal from '../../components/common/ErrorDetailsModal';
|
||||
|
||||
export default function Approved() {
|
||||
const toast = useToast();
|
||||
@@ -91,6 +92,10 @@ export default function Approved() {
|
||||
const [bulkScheduleItems, setBulkScheduleItems] = useState<Content[]>([]);
|
||||
const [bulkSchedulePreview, setBulkSchedulePreview] = useState<any>(null);
|
||||
|
||||
// Error details modal state
|
||||
const [showErrorDetailsModal, setShowErrorDetailsModal] = useState(false);
|
||||
const [errorContent, setErrorContent] = useState<Content | null>(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
|
||||
@@ -482,6 +487,9 @@ export default function Approved() {
|
||||
if (window.confirm(`Are you sure you want to unschedule "${row.title}"?`)) {
|
||||
await handleUnscheduleContent(row.id);
|
||||
}
|
||||
} else if (action === 'view_error') {
|
||||
setErrorContent(row);
|
||||
setShowErrorDetailsModal(true);
|
||||
} else if (action === 'edit') {
|
||||
// Navigate to content editor
|
||||
if (row.site_id) {
|
||||
@@ -609,8 +617,14 @@ export default function Approved() {
|
||||
const handleBulkAction = useCallback(async (action: string, ids: string[]) => {
|
||||
if (action === 'bulk_publish_site') {
|
||||
await handleBulkPublishToSite(ids);
|
||||
} else if (action === 'bulk_schedule_manual') {
|
||||
// Manual bulk scheduling (same time for all)
|
||||
handleBulkScheduleManual(ids);
|
||||
} else if (action === 'bulk_schedule_defaults') {
|
||||
// Schedule with site defaults
|
||||
handleBulkScheduleWithDefaults(ids);
|
||||
}
|
||||
}, [handleBulkPublishToSite]);
|
||||
}, [handleBulkPublishToSite, handleBulkScheduleManual, handleBulkScheduleWithDefaults]);
|
||||
|
||||
// Bulk status update handler
|
||||
const handleBulkUpdateStatus = useCallback(async (ids: string[], status: string) => {
|
||||
@@ -886,6 +900,28 @@ export default function Approved() {
|
||||
onChangeSettings={handleOpenSiteSettings}
|
||||
siteId={activeSite?.id || 0}
|
||||
/>
|
||||
|
||||
<ErrorDetailsModal
|
||||
isOpen={showErrorDetailsModal}
|
||||
onClose={() => {
|
||||
setShowErrorDetailsModal(false);
|
||||
setErrorContent(null);
|
||||
}}
|
||||
content={errorContent}
|
||||
site={activeSite}
|
||||
onPublishNow={() => {
|
||||
if (errorContent) {
|
||||
handleSinglePublish(errorContent);
|
||||
}
|
||||
}}
|
||||
onReschedule={() => {
|
||||
if (errorContent) {
|
||||
setScheduleContent(errorContent);
|
||||
setShowScheduleModal(true);
|
||||
}
|
||||
}}
|
||||
onFixSettings={handleOpenSiteSettings}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -636,8 +636,14 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
||||
const date = new Date(content.scheduled_publish_at);
|
||||
const localDateTime = date.toISOString().slice(0, 16);
|
||||
setScheduleDateTime(localDateTime);
|
||||
} else if (content?.site_status === 'failed') {
|
||||
// Default to tomorrow at 9 AM for failed items without a schedule
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(9, 0, 0, 0);
|
||||
setScheduleDateTime(tomorrow.toISOString().slice(0, 16));
|
||||
}
|
||||
}, [content?.scheduled_publish_at]);
|
||||
}, [content?.scheduled_publish_at, content?.site_status]);
|
||||
|
||||
// Handler to update schedule
|
||||
const handleUpdateSchedule = async () => {
|
||||
@@ -646,11 +652,22 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
||||
setIsUpdatingSchedule(true);
|
||||
try {
|
||||
const isoDateTime = new Date(scheduleDateTime).toISOString();
|
||||
await fetchAPI(`/v1/writer/content/${content.id}/schedule/`, {
|
||||
|
||||
// Use reschedule endpoint for failed items, schedule endpoint for scheduled items
|
||||
const endpoint = content.site_status === 'failed'
|
||||
? `/v1/writer/content/${content.id}/reschedule/`
|
||||
: `/v1/writer/content/${content.id}/schedule/`;
|
||||
|
||||
const body = content.site_status === 'failed'
|
||||
? JSON.stringify({ scheduled_at: isoDateTime })
|
||||
: JSON.stringify({ scheduled_publish_at: isoDateTime });
|
||||
|
||||
await fetchAPI(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ scheduled_publish_at: isoDateTime }),
|
||||
body,
|
||||
});
|
||||
toast.success('Schedule updated successfully');
|
||||
|
||||
toast.success(content.site_status === 'failed' ? 'Content rescheduled successfully' : 'Schedule updated successfully');
|
||||
setIsEditingSchedule(false);
|
||||
// Trigger content refresh by reloading the page
|
||||
window.location.reload();
|
||||
@@ -1180,8 +1197,8 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Schedule Date/Time Editor - Only for scheduled content */}
|
||||
{content.site_status === 'scheduled' && (
|
||||
{/* Schedule Date/Time Editor - For scheduled and failed content */}
|
||||
{(content.site_status === 'scheduled' || content.site_status === 'failed') && (
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditingSchedule ? (
|
||||
<>
|
||||
@@ -1198,7 +1215,7 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
||||
onClick={handleUpdateSchedule}
|
||||
disabled={isUpdatingSchedule || !scheduleDateTime}
|
||||
>
|
||||
{isUpdatingSchedule ? 'Updating...' : 'Update'}
|
||||
{isUpdatingSchedule ? 'Updating...' : content.site_status === 'failed' ? 'Reschedule' : 'Update'}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
@@ -1206,10 +1223,16 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
||||
tone="neutral"
|
||||
onClick={() => {
|
||||
setIsEditingSchedule(false);
|
||||
// Reset to original value
|
||||
// Reset to original value or tomorrow if failed
|
||||
if (content.scheduled_publish_at) {
|
||||
const date = new Date(content.scheduled_publish_at);
|
||||
setScheduleDateTime(date.toISOString().slice(0, 16));
|
||||
} else if (content.site_status === 'failed') {
|
||||
// Default to tomorrow for failed items
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(9, 0, 0, 0);
|
||||
setScheduleDateTime(tomorrow.toISOString().slice(0, 16));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1219,12 +1242,17 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{content.scheduled_publish_at ? formatDate(content.scheduled_publish_at) : ''}
|
||||
{content.site_status === 'failed' && (
|
||||
content.scheduled_publish_at ? `Was scheduled: ${formatDate(content.scheduled_publish_at)}` : 'Not scheduled'
|
||||
)}
|
||||
{content.site_status === 'scheduled' && (
|
||||
content.scheduled_publish_at ? formatDate(content.scheduled_publish_at) : ''
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setIsEditingSchedule(true)}
|
||||
className="text-brand-600 hover:text-brand-700 dark:text-brand-400 dark:hover:text-brand-300 p-1"
|
||||
title="Edit schedule"
|
||||
title={content.site_status === 'failed' ? 'Reschedule' : 'Edit schedule'}
|
||||
>
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -1233,6 +1261,15 @@ export default function ContentViewTemplate({ content, loading, onBack }: Conten
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message for failed items */}
|
||||
{content.site_status === 'failed' && content.site_status_message && (
|
||||
<div className="flex items-center gap-2 px-3 py-1 bg-red-50 dark:bg-red-900/20 rounded border border-red-200 dark:border-red-800">
|
||||
<span className="text-xs text-red-600 dark:text-red-400">
|
||||
{content.site_status_message}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{content.external_url && content.site_status === 'published' && (
|
||||
<a
|
||||
href={content.external_url}
|
||||
|
||||
Reference in New Issue
Block a user