SEction 2 part 2
This commit is contained in:
683
frontend/src/pages/Publisher/ContentCalendar.tsx
Normal file
683
frontend/src/pages/Publisher/ContentCalendar.tsx
Normal file
@@ -0,0 +1,683 @@
|
||||
/**
|
||||
* 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<Content> {
|
||||
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<Content> {
|
||||
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<Content[]>([]);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('calendar'); // Default to calendar view
|
||||
const [draggedItem, setDraggedItem] = useState<Content | null>(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 (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-brand-100 text-brand-700 dark:bg-brand-900 dark:text-brand-300">
|
||||
<ArrowRightIcon className="w-3 h-3 animate-pulse" />
|
||||
Publishing...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-warning-100 text-warning-700 dark:bg-warning-900 dark:text-warning-300">
|
||||
<ClockIcon className="w-3 h-3" />
|
||||
Scheduled
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Content Calendar" description="View and manage scheduled content" />
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-4">
|
||||
<CalendarIcon className="w-12 h-12 text-gray-400" />
|
||||
<p className="text-gray-600 dark:text-gray-400">Please select a site from the header to view the content calendar</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Content Calendar" description="View and manage scheduled content" />
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading calendar...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<PageMeta title="Content Calendar" description="View and manage scheduled content" />
|
||||
|
||||
{/* Header - Site selector is in app header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
|
||||
<PageHeader
|
||||
title="Content Calendar"
|
||||
badge={{ icon: <CalendarIcon />, color: 'amber' }}
|
||||
hideSiteSector
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview - New layout with count on right, bigger labels, descriptions */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
{/* Review */}
|
||||
<Card
|
||||
className="p-4 cursor-pointer hover:border-purple-500 transition-colors group"
|
||||
onClick={() => navigate('/writer/review')}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="size-8 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||
<PencilIcon className="w-4 h-4 text-purple-600" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white">Review</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Content awaiting review before approval</p>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-purple-600 dark:text-purple-400">{stats.review}</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Approved */}
|
||||
<Card
|
||||
className="p-4 cursor-pointer hover:border-success-500 transition-colors group"
|
||||
onClick={() => navigate('/writer/approved')}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="size-8 rounded-lg bg-success-100 dark:bg-success-900/30 flex items-center justify-center">
|
||||
<CheckCircleIcon className="w-4 h-4 text-success-600" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white">Approved</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Ready to be scheduled for publishing</p>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-success-600 dark:text-success-400">{stats.approved}</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Published */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="size-8 rounded-lg bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
||||
<ArrowRightIcon className="w-4 h-4 text-brand-600" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white">Published</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Successfully published to your site</p>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-brand-600 dark:text-brand-400">{stats.published}</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Scheduled */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="size-8 rounded-lg bg-warning-100 dark:bg-warning-900/30 flex items-center justify-center">
|
||||
<ClockIcon className="w-4 h-4 text-warning-600" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-white">Scheduled</h3>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Queued for automatic publishing</p>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-warning-600 dark:text-warning-400">{stats.scheduled}</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 30-day summary */}
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
<span className="font-semibold text-brand-600">{stats.publishedLast30Days}</span> published in last 30 days
|
||||
</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">
|
||||
<span className="font-semibold text-warning-600">{stats.scheduledNext30Days}</span> scheduled for next 30 days
|
||||
</span>
|
||||
</div>
|
||||
<ButtonGroup>
|
||||
<ButtonGroupItem
|
||||
isActive={viewMode === 'calendar'}
|
||||
onClick={() => setViewMode('calendar')}
|
||||
startIcon={<CalendarIcon className="w-4 h-4" />}
|
||||
>
|
||||
Calendar
|
||||
</ButtonGroupItem>
|
||||
<ButtonGroupItem
|
||||
isActive={viewMode === 'list'}
|
||||
onClick={() => setViewMode('list')}
|
||||
startIcon={<ListIcon className="w-4 h-4" />}
|
||||
>
|
||||
List
|
||||
</ButtonGroupItem>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area with Sidebar */}
|
||||
<div className="flex gap-6">
|
||||
{/* Main Calendar/List View */}
|
||||
<div className="flex-1">
|
||||
{queueItems.length === 0 && approvedItems.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<CalendarIcon className="w-12 h-12 mx-auto text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||
No content to schedule
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Approve content from the review queue to schedule for publishing.
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => navigate('/writer/review')}>
|
||||
Go to Review Queue
|
||||
</Button>
|
||||
</Card>
|
||||
) : viewMode === 'list' ? (
|
||||
/* List View */
|
||||
<ComponentCard title="Publishing Queue" desc="Drag items to reorder or drag from sidebar to add.">
|
||||
<div
|
||||
className="space-y-2 min-h-[200px]"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => {
|
||||
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 ? (
|
||||
<div className="flex items-center justify-center h-48 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg">
|
||||
<p className="text-gray-500 dark:text-gray-400">Drop approved content here to schedule</p>
|
||||
</div>
|
||||
) : (
|
||||
queueItems.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
draggable
|
||||
onDragStart={(e) => 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
|
||||
`}
|
||||
>
|
||||
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-700 flex items-center justify-center text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{item.title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-3 mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="flex items-center gap-1">
|
||||
<ClockIcon className="w-3.5 h-3.5" />
|
||||
{formatScheduledTime(item.scheduled_publish_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{getStatusBadge(item)}
|
||||
<div className="flex items-center gap-1">
|
||||
<IconButton
|
||||
icon={<EyeIcon className="w-4 h-4" />}
|
||||
variant="ghost"
|
||||
tone="neutral"
|
||||
size="sm"
|
||||
onClick={() => handleViewContent(item)}
|
||||
title="View content"
|
||||
/>
|
||||
<IconButton
|
||||
icon={<TrashBinIcon className="w-4 h-4" />}
|
||||
variant="ghost"
|
||||
tone="danger"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveFromQueue(item)}
|
||||
title="Remove from queue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
) : (
|
||||
/* Calendar View with drag-drop */
|
||||
<ComponentCard title="Calendar View" desc="Drag content from sidebar to schedule. Published items shown with glass effect.">
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{/* Day headers */}
|
||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
|
||||
<div key={day} className="text-center text-sm font-medium text-gray-500 dark:text-gray-400 py-2">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 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 (
|
||||
<div
|
||||
key={index}
|
||||
onDragOver={!isPast ? handleDragOver : undefined}
|
||||
onDrop={!isPast ? (e) => 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' : ''}
|
||||
`}
|
||||
>
|
||||
<div className={`text-sm font-medium mb-1 ${isToday ? 'text-brand-600' : isPast ? 'text-gray-400 dark:text-gray-500' : 'text-gray-600 dark:text-gray-400'}`}>
|
||||
{date.getDate()}
|
||||
{date.getDate() === 1 && (
|
||||
<span className="ml-1 text-xs">
|
||||
{date.toLocaleString('default', { month: 'short' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{/* Published items with glass effect */}
|
||||
{publishedOnDate.slice(0, 2).map(item => (
|
||||
<CalendarItemTooltip
|
||||
key={item.id}
|
||||
title={item.title}
|
||||
status="published"
|
||||
contentType={item.content_type || 'Article'}
|
||||
date={item.site_status_updated_at}
|
||||
dateLabel="Published"
|
||||
placement="top"
|
||||
>
|
||||
<div
|
||||
onClick={() => 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}
|
||||
</div>
|
||||
</CalendarItemTooltip>
|
||||
))}
|
||||
{/* Scheduled items */}
|
||||
{scheduledItems.slice(0, 3 - publishedOnDate.length).map(item => (
|
||||
<CalendarItemTooltip
|
||||
key={item.id}
|
||||
title={item.title}
|
||||
status="scheduled"
|
||||
contentType={item.content_type || 'Article'}
|
||||
date={item.scheduled_publish_at}
|
||||
dateLabel="Scheduled"
|
||||
placement="top"
|
||||
>
|
||||
<div
|
||||
draggable={!isPast}
|
||||
onDragStart={!isPast ? (e) => 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}
|
||||
</div>
|
||||
</CalendarItemTooltip>
|
||||
))}
|
||||
{(scheduledItems.length + publishedOnDate.length) > 3 && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
+{(scheduledItems.length + publishedOnDate.length) - 3} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Approved Content Sidebar - reduced width by 15% (80 -> 68) */}
|
||||
<div className="w-68 flex-shrink-0">
|
||||
<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 ? (
|
||||
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<FileTextIcon className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No approved content</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => navigate('/writer/approved')}
|
||||
>
|
||||
View All Approved
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
approvedItems.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
draggable
|
||||
onDragStart={(e) => 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
|
||||
`}
|
||||
>
|
||||
<h4 className="font-medium text-gray-900 dark:text-white text-sm truncate">
|
||||
{item.title}
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-success-600 dark:text-success-400 flex items-center gap-1">
|
||||
<CheckCircleIcon className="w-3 h-3" />
|
||||
Approved
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user