/** * Content Calendar Page * Shows scheduled content for publishing to external site * Allows reordering, pausing, and viewing calendar with drag-drop support * Site selector in app header (inherited from layout) * * Content Statuses: * - status: 'draft' | 'review' | 'approved' | 'published' (content workflow status) * - site_status: 'not_published' | 'scheduled' | 'publishing' | 'published' | 'failed' (publishing status) */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import PageMeta from '../../components/common/PageMeta'; import PageHeader from '../../components/common/PageHeader'; import ComponentCard from '../../components/common/ComponentCard'; import { Card } from '../../components/ui/card'; import Button from '../../components/ui/button/Button'; import IconButton from '../../components/ui/button/IconButton'; import { ButtonGroup, ButtonGroupItem } from '../../components/ui/button-group/ButtonGroup'; 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 { ClockIcon, CheckCircleIcon, ArrowRightIcon, CalendarIcon, ListIcon, TrashBinIcon, EyeIcon, PencilIcon, FileTextIcon, } from '../../icons'; type ViewMode = 'list' | 'calendar'; // API function to schedule content async function scheduleContent(contentId: number, scheduledDate: string): Promise { return fetchAPI(`/v1/writer/content/${contentId}/schedule/`, { method: 'POST', body: JSON.stringify({ scheduled_publish_at: scheduledDate }), }); } // API function to unschedule content async function unscheduleContent(contentId: number): Promise { return fetchAPI(`/v1/writer/content/${contentId}/unschedule/`, { method: 'POST', }); } export default function ContentCalendar() { const navigate = useNavigate(); const toast = useToast(); const { activeSite } = useSiteStore(); const [loading, setLoading] = useState(true); const [allContent, setAllContent] = useState([]); const [viewMode, setViewMode] = useState('calendar'); // Default to calendar view const [draggedItem, setDraggedItem] = useState(null); // Derived state: Queue items (scheduled or publishing - future dates only for queue) const queueItems = useMemo(() => { return allContent .filter((c: Content) => c.site_status === 'scheduled' || c.site_status === 'publishing') .sort((a: Content, b: Content) => { const dateA = a.scheduled_publish_at ? new Date(a.scheduled_publish_at).getTime() : 0; const dateB = b.scheduled_publish_at ? new Date(b.scheduled_publish_at).getTime() : 0; return dateA - dateB; }); }, [allContent]); // Derived state: Published items (site_status = 'published') const publishedItems = useMemo(() => { return allContent.filter((c: Content) => c.site_status === 'published'); }, [allContent]); // Derived state: Approved items for sidebar (approved but not scheduled/publishing/published) const approvedItems = useMemo(() => { return allContent.filter( (c: Content) => c.status === 'approved' && c.site_status !== 'scheduled' && c.site_status !== 'publishing' && c.site_status !== 'published' ); }, [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); // Published in last 30 days const publishedLast30Days = allContent.filter((c: Content) => { if (c.site_status !== 'published') return false; const publishDate = c.site_status_updated_at ? new Date(c.site_status_updated_at) : null; return publishDate && publishDate >= thirtyDaysAgo; }).length; // Scheduled in next 30 days const scheduledNext30Days = allContent.filter((c: Content) => { if (c.site_status !== 'scheduled') return false; const schedDate = c.scheduled_publish_at ? new Date(c.scheduled_publish_at) : null; return schedDate && schedDate >= now && schedDate <= thirtyDaysFromNow; }).length; return { scheduled: allContent.filter((c: Content) => c.site_status === 'scheduled').length, publishing: allContent.filter((c: Content) => c.site_status === 'publishing').length, published: allContent.filter((c: Content) => c.site_status === 'published').length, review: allContent.filter((c: Content) => c.status === 'review').length, approved: allContent.filter((c: Content) => c.status === 'approved' && c.site_status !== 'published').length, publishedLast30Days, scheduledNext30Days, }; }, [allContent]); const loadQueue = useCallback(async () => { if (!activeSite?.id) return; try { setLoading(true); // Fetch all content for this site const response = await fetchContent({ site_id: activeSite.id, page_size: 200, }); setAllContent(response.results || []); } catch (error: any) { toast.error(`Failed to load content: ${error.message}`); } finally { setLoading(false); } }, [activeSite?.id, toast]); useEffect(() => { if (activeSite?.id) { loadQueue(); } }, [activeSite?.id, loadQueue]); // Drag and drop handlers for list view const handleDragStart = (e: React.DragEvent, item: Content, source: 'queue' | 'approved') => { setDraggedItem(item); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('source', source); }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }; const handleDropOnList = async (e: React.DragEvent, targetItem: Content) => { e.preventDefault(); if (!draggedItem || draggedItem.id === targetItem.id) return; // If from approved, schedule it const fromApproved = approvedItems.some(i => i.id === draggedItem.id); if (fromApproved) { try { // Schedule for tomorrow by default when dropping on list const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setHours(9, 0, 0, 0); await scheduleContent(draggedItem.id, tomorrow.toISOString()); toast.success(`Scheduled for ${tomorrow.toLocaleDateString()}`); loadQueue(); // Reload to get updated data } catch (error: any) { toast.error(`Failed to schedule: ${error.message}`); } } setDraggedItem(null); }; // Calendar drag and drop const handleDropOnCalendarDate = async (e: React.DragEvent, date: Date) => { e.preventDefault(); if (!draggedItem) return; // Set the scheduled time to 9 AM on the target date const newDate = new Date(date); newDate.setHours(9, 0, 0, 0); try { await scheduleContent(draggedItem.id, newDate.toISOString()); toast.success(`Scheduled for ${newDate.toLocaleDateString()}`); loadQueue(); // Reload to get updated data from server } catch (error: any) { toast.error(`Failed to schedule: ${error.message}`); } setDraggedItem(null); }; const handleDragEnd = () => { setDraggedItem(null); }; const handleRemoveFromQueue = async (item: Content) => { try { await unscheduleContent(item.id); toast.success('Removed from queue'); loadQueue(); // Reload to get updated data } catch (error: any) { toast.error(`Failed to remove: ${error.message}`); } }; const handleViewContent = (item: Content) => { navigate(`/writer/content/${item.id}`); }; const formatScheduledTime = (dateStr: string | null | undefined) => { if (!dateStr) return 'Not scheduled'; const date = new Date(dateStr); return date.toLocaleString('en-US', { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true, }); }; const getStatusBadge = (item: Content) => { if (item.site_status === 'publishing') { return ( Publishing... ); } return ( Scheduled ); }; // Calendar view helpers const getCalendarDays = () => { const today = new Date(); const days = []; // Start from beginning of current week const startOfWeek = new Date(today); startOfWeek.setDate(today.getDate() - today.getDay()); // Show 4 weeks for (let i = 0; i < 28; i++) { const date = new Date(startOfWeek); date.setDate(startOfWeek.getDate() + i); days.push(date); } return days; }; // Get scheduled items for a specific date const getScheduledItemsForDate = (date: Date) => { return queueItems.filter(item => { if (!item.scheduled_publish_at) return false; const itemDate = new Date(item.scheduled_publish_at); return ( itemDate.getDate() === date.getDate() && itemDate.getMonth() === date.getMonth() && itemDate.getFullYear() === date.getFullYear() ); }); }; // Get published items for a specific date const getPublishedItemsForDate = (date: Date) => { return publishedItems.filter(item => { // Use site_status_updated_at as publish date const publishDate = item.site_status_updated_at || item.updated_at; if (!publishDate) return false; const itemDate = new Date(publishDate); return ( itemDate.getDate() === date.getDate() && itemDate.getMonth() === date.getMonth() && itemDate.getFullYear() === date.getFullYear() ); }); }; if (!activeSite) { return (

Please select a site from the header to view the content calendar

); } if (loading) { return (
Loading calendar...
); } return (
{/* Header - Site selector is in app header */}
, color: 'amber' }} hideSiteSector />
{/* Stats Overview - New layout with count on right, bigger labels, descriptions */}
{/* Review */} navigate('/writer/review')} >

Review

Content awaiting review before approval

{stats.review}

{/* Approved */} navigate('/writer/approved')} >

Approved

Ready to be scheduled for publishing

{stats.approved}

{/* Published */}

Published

Successfully published to your site

{stats.published}

{/* Scheduled */}

Scheduled

Queued for automatic publishing

{stats.scheduled}

{/* 30-day summary */}
{stats.publishedLast30Days} published in last 30 days {stats.scheduledNext30Days} scheduled for next 30 days
setViewMode('calendar')} startIcon={} > Calendar setViewMode('list')} startIcon={} > List
{/* Main Content Area with Sidebar */}
{/* Main Calendar/List View */}
{queueItems.length === 0 && approvedItems.length === 0 ? (

No content to schedule

Approve content from the review queue to schedule for publishing.

) : viewMode === 'list' ? ( /* List View */
{ e.preventDefault(); if (!draggedItem) return; // If dropping on empty area, schedule for tomorrow const fromApproved = approvedItems.some(i => i.id === draggedItem.id); if (fromApproved) { const tomorrow = new Date(); tomorrow.setDate(tomorrow.getDate() + 1); tomorrow.setHours(9, 0, 0, 0); scheduleContent(draggedItem.id, tomorrow.toISOString()) .then(() => { toast.success(`Scheduled for ${tomorrow.toLocaleDateString()}`); loadQueue(); }) .catch((err) => toast.error(`Failed to schedule: ${err.message}`)); } setDraggedItem(null); }} > {queueItems.length === 0 ? (

Drop approved content here to schedule

) : ( queueItems.map((item, index) => (
handleDragStart(e, item, 'queue')} onDragOver={handleDragOver} onDrop={(e) => handleDropOnList(e, item)} onDragEnd={handleDragEnd} className={` flex items-center gap-4 p-4 bg-white dark:bg-gray-800 rounded-lg border-2 ${draggedItem?.id === item.id ? 'border-brand-500 opacity-50' : 'border-gray-200 dark:border-gray-700'} hover:border-brand-300 dark:hover:border-brand-700 transition-all cursor-move `} >
{index + 1}

{item.title}

{formatScheduledTime(item.scheduled_publish_at)}
{getStatusBadge(item)}
} variant="ghost" tone="neutral" size="sm" onClick={() => handleViewContent(item)} title="View content" /> } variant="ghost" tone="danger" size="sm" onClick={() => handleRemoveFromQueue(item)} title="Remove from queue" />
)) )}
) : ( /* Calendar View with drag-drop */
{/* Day headers */} {['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
{day}
))} {/* Calendar days */} {getCalendarDays().map((date, index) => { const scheduledItems = getScheduledItemsForDate(date); const publishedOnDate = getPublishedItemsForDate(date); const isToday = date.toDateString() === new Date().toDateString(); const isPast = date < new Date(new Date().setHours(0, 0, 0, 0)); return (
handleDropOnCalendarDate(e, date) : undefined} className={` min-h-[100px] p-2 rounded-lg border transition-colors ${isToday ? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20' : isPast ? 'border-gray-100 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-900/30' : 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800' } ${!isPast && draggedItem ? 'border-dashed border-brand-300 dark:border-brand-600' : ''} `} >
{date.getDate()} {date.getDate() === 1 && ( {date.toLocaleString('default', { month: 'short' })} )}
{/* Published items with glass effect */} {publishedOnDate.slice(0, 2).map(item => (
handleViewContent(item)} className="text-xs p-1.5 bg-success-100/60 dark:bg-success-900/20 text-success-700 dark:text-success-300 rounded truncate cursor-pointer backdrop-blur-sm border border-success-200/50 dark:border-success-800/50" > ✓ {item.title}
))} {/* Scheduled items */} {scheduledItems.slice(0, 3 - publishedOnDate.length).map(item => (
handleDragStart(e, item, 'queue') : undefined} onDragEnd={handleDragEnd} onClick={() => handleViewContent(item)} className={`text-xs p-1.5 rounded truncate transition-colors ${ isPast ? 'bg-gray-100/60 dark:bg-gray-800/40 text-gray-500 dark:text-gray-400 backdrop-blur-sm cursor-default' : 'bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-200 cursor-move hover:bg-warning-200 dark:hover:bg-warning-900/50' }`} > {item.title}
))} {(scheduledItems.length + publishedOnDate.length) > 3 && (
+{(scheduledItems.length + publishedOnDate.length) - 3} more
)}
); })}
)}
{/* Approved Content Sidebar - reduced width by 15% (80 -> 68) */}
{approvedItems.length === 0 ? (

No approved content

) : ( approvedItems.map(item => (
handleDragStart(e, item, 'approved')} onDragEnd={handleDragEnd} className={` p-3 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 ${draggedItem?.id === item.id ? 'opacity-50 border-brand-500' : ''} hover:border-success-300 dark:hover:border-success-700 transition-colors cursor-move `} >

{item.title}

Approved
)) )}
); }