Section 2 Part 3
This commit is contained in:
@@ -35,16 +35,23 @@ import {
|
||||
|
||||
type ViewMode = 'list' | 'calendar';
|
||||
|
||||
// API function to schedule content
|
||||
async function scheduleContent(contentId: number, scheduledDate: string): Promise<Content> {
|
||||
// Type for schedule API response (partial content data)
|
||||
interface ScheduleResponse {
|
||||
content_id: number;
|
||||
site_status: string;
|
||||
scheduled_publish_at: string;
|
||||
}
|
||||
|
||||
// API function to schedule content - returns partial data, not full Content
|
||||
async function scheduleContent(contentId: number, scheduledDate: string): Promise<ScheduleResponse> {
|
||||
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> {
|
||||
// API function to unschedule content - returns partial data
|
||||
async function unscheduleContent(contentId: number): Promise<{ content_id: number; site_status: string }> {
|
||||
return fetchAPI(`/v1/writer/content/${contentId}/unschedule/`, {
|
||||
method: 'POST',
|
||||
});
|
||||
@@ -59,11 +66,15 @@ export default function ContentCalendar() {
|
||||
const [allContent, setAllContent] = useState<Content[]>([]);
|
||||
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
|
||||
|
||||
// Derived state: Queue items (scheduled or publishing - future dates only for queue)
|
||||
// Derived state: Queue items (scheduled or publishing - exclude already published)
|
||||
const queueItems = useMemo(() => {
|
||||
return allContent
|
||||
.filter((c: Content) => c.site_status === 'scheduled' || c.site_status === 'publishing')
|
||||
.filter((c: Content) =>
|
||||
(c.site_status === 'scheduled' || c.site_status === 'publishing') &&
|
||||
(!c.external_id || c.external_id === '') // Exclude already published items
|
||||
)
|
||||
.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;
|
||||
@@ -71,19 +82,19 @@ export default function ContentCalendar() {
|
||||
});
|
||||
}, [allContent]);
|
||||
|
||||
// Derived state: Published items (site_status = 'published')
|
||||
// Derived state: Published items (have external_id - same logic as Content Approved page)
|
||||
const publishedItems = useMemo(() => {
|
||||
return allContent.filter((c: Content) => c.site_status === 'published');
|
||||
return allContent.filter((c: Content) => c.external_id && c.external_id !== '');
|
||||
}, [allContent]);
|
||||
|
||||
// Derived state: Approved items for sidebar (approved but not scheduled/publishing/published)
|
||||
// Derived state: Approved items for sidebar (approved but not published to site)
|
||||
const approvedItems = useMemo(() => {
|
||||
return allContent.filter(
|
||||
(c: Content) =>
|
||||
c.status === 'approved' &&
|
||||
(!c.external_id || c.external_id === '') && // Not published to site
|
||||
c.site_status !== 'scheduled' &&
|
||||
c.site_status !== 'publishing' &&
|
||||
c.site_status !== 'published'
|
||||
c.site_status !== 'publishing'
|
||||
);
|
||||
}, [allContent]);
|
||||
|
||||
@@ -93,26 +104,31 @@ export default function ContentCalendar() {
|
||||
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
|
||||
// Published in last 30 days (items with external_id)
|
||||
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;
|
||||
if (!c.external_id || c.external_id === '') return false;
|
||||
// Use updated_at as publish date since site_status_updated_at may not be set
|
||||
const publishDate = c.updated_at ? new Date(c.updated_at) : null;
|
||||
return publishDate && publishDate >= thirtyDaysAgo;
|
||||
}).length;
|
||||
|
||||
// Scheduled in next 30 days
|
||||
// Scheduled in next 30 days (exclude already published items with external_id)
|
||||
const scheduledNext30Days = allContent.filter((c: Content) => {
|
||||
if (c.site_status !== 'scheduled') return false;
|
||||
if (c.external_id && c.external_id !== '') return false; // Exclude already published
|
||||
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,
|
||||
// Scheduled count excludes items that are already published (have external_id)
|
||||
scheduled: allContent.filter((c: Content) =>
|
||||
c.site_status === 'scheduled' && (!c.external_id || c.external_id === '')
|
||||
).length,
|
||||
publishing: allContent.filter((c: Content) => c.site_status === 'publishing').length,
|
||||
published: allContent.filter((c: Content) => c.site_status === 'published').length,
|
||||
published: allContent.filter((c: Content) => c.external_id && c.external_id !== '').length,
|
||||
review: allContent.filter((c: Content) => c.status === 'review').length,
|
||||
approved: allContent.filter((c: Content) => c.status === 'approved' && c.site_status !== 'published').length,
|
||||
approved: allContent.filter((c: Content) => c.status === 'approved' && (!c.external_id || c.external_id === '')).length,
|
||||
publishedLast30Days,
|
||||
scheduledNext30Days,
|
||||
};
|
||||
@@ -130,6 +146,20 @@ export default function ContentCalendar() {
|
||||
page_size: 200,
|
||||
});
|
||||
|
||||
// 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
|
||||
})));
|
||||
|
||||
setAllContent(response.results || []);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load content: ${error.message}`);
|
||||
@@ -142,7 +172,7 @@ export default function ContentCalendar() {
|
||||
if (activeSite?.id) {
|
||||
loadQueue();
|
||||
}
|
||||
}, [activeSite?.id, loadQueue]);
|
||||
}, [activeSite?.id]); // Removed loadQueue from dependencies to prevent reload loops
|
||||
|
||||
// Drag and drop handlers for list view
|
||||
const handleDragStart = (e: React.DragEvent, item: Content, source: 'queue' | 'approved') => {
|
||||
@@ -170,9 +200,17 @@ export default function ContentCalendar() {
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(9, 0, 0, 0);
|
||||
|
||||
await scheduleContent(draggedItem.id, tomorrow.toISOString());
|
||||
const response = await scheduleContent(draggedItem.id, tomorrow.toISOString());
|
||||
toast.success(`Scheduled for ${tomorrow.toLocaleDateString()}`);
|
||||
loadQueue(); // Reload to get updated data
|
||||
|
||||
// Merge API response with existing content - API returns partial data
|
||||
setAllContent(prevContent => [
|
||||
...prevContent.map(c => c.id === draggedItem.id ? {
|
||||
...c,
|
||||
site_status: response.site_status,
|
||||
scheduled_publish_at: response.scheduled_publish_at,
|
||||
} : c)
|
||||
]);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to schedule: ${error.message}`);
|
||||
}
|
||||
@@ -191,9 +229,17 @@ export default function ContentCalendar() {
|
||||
newDate.setHours(9, 0, 0, 0);
|
||||
|
||||
try {
|
||||
await scheduleContent(draggedItem.id, newDate.toISOString());
|
||||
const response = await scheduleContent(draggedItem.id, newDate.toISOString());
|
||||
toast.success(`Scheduled for ${newDate.toLocaleDateString()}`);
|
||||
loadQueue(); // Reload to get updated data from server
|
||||
|
||||
// Merge API response with existing content - API returns partial data
|
||||
setAllContent(prevContent => [
|
||||
...prevContent.map(c => c.id === draggedItem.id ? {
|
||||
...c,
|
||||
site_status: response.site_status,
|
||||
scheduled_publish_at: response.scheduled_publish_at,
|
||||
} : c)
|
||||
]);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to schedule: ${error.message}`);
|
||||
}
|
||||
@@ -209,7 +255,15 @@ export default function ContentCalendar() {
|
||||
try {
|
||||
await unscheduleContent(item.id);
|
||||
toast.success('Removed from queue');
|
||||
loadQueue(); // Reload to get updated data
|
||||
|
||||
// Update state directly - set site_status to not_published and clear scheduled_publish_at
|
||||
setAllContent(prevContent => [
|
||||
...prevContent.map(c => c.id === item.id ? {
|
||||
...c,
|
||||
site_status: 'not_published',
|
||||
scheduled_publish_at: null,
|
||||
} : c)
|
||||
]);
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to remove: ${error.message}`);
|
||||
}
|
||||
@@ -251,24 +305,56 @@ export default function ContentCalendar() {
|
||||
|
||||
// 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());
|
||||
// Get first day of the month
|
||||
const firstDayOfMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth(), 1);
|
||||
// Get last day of the month
|
||||
const lastDayOfMonth = new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 0);
|
||||
|
||||
// Show 4 weeks
|
||||
for (let i = 0; i < 28; i++) {
|
||||
const date = new Date(startOfWeek);
|
||||
date.setDate(startOfWeek.getDate() + i);
|
||||
// Start from the Sunday before or on the first day of month
|
||||
const startDate = new Date(firstDayOfMonth);
|
||||
startDate.setDate(startDate.getDate() - startDate.getDay());
|
||||
|
||||
// Calculate how many days to show (must be multiple of 7)
|
||||
const daysInMonth = lastDayOfMonth.getDate();
|
||||
const startDayOffset = firstDayOfMonth.getDay();
|
||||
const totalCells = Math.ceil((daysInMonth + startDayOffset) / 7) * 7;
|
||||
|
||||
// Generate all days for the calendar grid
|
||||
for (let i = 0; i < totalCells; i++) {
|
||||
const date = new Date(startDate);
|
||||
date.setDate(startDate.getDate() + i);
|
||||
days.push(date);
|
||||
}
|
||||
return days;
|
||||
};
|
||||
|
||||
// Get scheduled items for a specific date
|
||||
// Navigation functions
|
||||
const goToPreviousMonth = () => {
|
||||
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1, 1));
|
||||
};
|
||||
|
||||
const goToNextMonth = () => {
|
||||
setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1, 1));
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
setCurrentMonth(new Date());
|
||||
};
|
||||
|
||||
// Truncate title to max words
|
||||
const truncateTitle = (title: string | undefined, maxWords: number = 7) => {
|
||||
if (!title) return '';
|
||||
const words = title.split(' ');
|
||||
if (words.length <= maxWords) return title;
|
||||
return words.slice(0, maxWords).join(' ') + '...';
|
||||
};
|
||||
|
||||
// Get scheduled items for a specific date (exclude already published)
|
||||
const getScheduledItemsForDate = (date: Date) => {
|
||||
return queueItems.filter(item => {
|
||||
// Skip if already published (has external_id)
|
||||
if (item.external_id && item.external_id !== '') return false;
|
||||
if (!item.scheduled_publish_at) return false;
|
||||
const itemDate = new Date(item.scheduled_publish_at);
|
||||
return (
|
||||
@@ -282,8 +368,8 @@ export default function ContentCalendar() {
|
||||
// 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;
|
||||
// Use updated_at as publish date (when external_id was set)
|
||||
const publishDate = item.updated_at;
|
||||
if (!publishDate) return false;
|
||||
const itemDate = new Date(publishDate);
|
||||
return (
|
||||
@@ -321,14 +407,11 @@ export default function ContentCalendar() {
|
||||
<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>
|
||||
<PageHeader
|
||||
title="Content Calendar"
|
||||
badge={{ icon: <CalendarIcon />, color: 'amber' }}
|
||||
hideSiteSector
|
||||
/>
|
||||
|
||||
{/* 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">
|
||||
@@ -464,9 +547,11 @@ export default function ContentCalendar() {
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(9, 0, 0, 0);
|
||||
scheduleContent(draggedItem.id, tomorrow.toISOString())
|
||||
.then(() => {
|
||||
.then((updatedContent) => {
|
||||
toast.success(`Scheduled for ${tomorrow.toLocaleDateString()}`);
|
||||
loadQueue();
|
||||
setAllContent(prevContent => [
|
||||
...prevContent.map(c => c.id === draggedItem.id ? updatedContent : c)
|
||||
]);
|
||||
})
|
||||
.catch((err) => toast.error(`Failed to schedule: ${err.message}`));
|
||||
}
|
||||
@@ -532,7 +617,40 @@ export default function ContentCalendar() {
|
||||
</ComponentCard>
|
||||
) : (
|
||||
/* Calendar View with drag-drop */
|
||||
<ComponentCard title="Calendar View" desc="Drag content from sidebar to schedule. Published items shown with glass effect.">
|
||||
<ComponentCard
|
||||
title="Calendar View"
|
||||
desc="Drag content from sidebar to schedule."
|
||||
headerContent={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={goToToday}
|
||||
>
|
||||
Today
|
||||
</Button>
|
||||
<IconButton
|
||||
icon={<span className="text-lg">‹</span>}
|
||||
variant="ghost"
|
||||
tone="neutral"
|
||||
size="sm"
|
||||
onClick={goToPreviousMonth}
|
||||
title="Previous month"
|
||||
/>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white px-2">
|
||||
{currentMonth.toLocaleString('default', { month: 'long', year: 'numeric' })}
|
||||
</h3>
|
||||
<IconButton
|
||||
icon={<span className="text-lg">›</span>}
|
||||
variant="ghost"
|
||||
tone="neutral"
|
||||
size="sm"
|
||||
onClick={goToNextMonth}
|
||||
title="Next month"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-7 gap-2">
|
||||
{/* Day headers */}
|
||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => (
|
||||
@@ -547,6 +665,9 @@ export default function ContentCalendar() {
|
||||
const publishedOnDate = getPublishedItemsForDate(date);
|
||||
const isToday = date.toDateString() === new Date().toDateString();
|
||||
const isPast = date < new Date(new Date().setHours(0, 0, 0, 0));
|
||||
const isCurrentMonth = date.getMonth() === currentMonth.getMonth();
|
||||
const totalItems = scheduledItems.length + publishedOnDate.length;
|
||||
const hasMoreThan5 = totalItems > 5;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -554,13 +675,14 @@ export default function ContentCalendar() {
|
||||
onDragOver={!isPast ? handleDragOver : undefined}
|
||||
onDrop={!isPast ? (e) => handleDropOnCalendarDate(e, date) : undefined}
|
||||
className={`
|
||||
min-h-[100px] p-2 rounded-lg border transition-colors
|
||||
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'
|
||||
}
|
||||
${!isCurrentMonth ? 'opacity-40' : ''}
|
||||
${!isPast && draggedItem ? 'border-dashed border-brand-300 dark:border-brand-600' : ''}
|
||||
`}
|
||||
>
|
||||
@@ -574,26 +696,26 @@ export default function ContentCalendar() {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{/* Published items with glass effect */}
|
||||
{publishedOnDate.slice(0, 2).map(item => (
|
||||
{publishedOnDate.slice(0, 5).map(item => (
|
||||
<CalendarItemTooltip
|
||||
key={item.id}
|
||||
title={item.title}
|
||||
status="published"
|
||||
contentType={item.content_type || 'Article'}
|
||||
date={item.site_status_updated_at}
|
||||
date={item.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"
|
||||
className="text-xs p-1.5 bg-success-100/60 dark:bg-success-900/20 text-success-700 dark:text-success-300 rounded cursor-pointer backdrop-blur-sm border border-success-200/50 dark:border-success-800/50 break-words"
|
||||
>
|
||||
✓ {item.title}
|
||||
✓ {truncateTitle(item.title, 7)}
|
||||
</div>
|
||||
</CalendarItemTooltip>
|
||||
))}
|
||||
{/* Scheduled items */}
|
||||
{scheduledItems.slice(0, 3 - publishedOnDate.length).map(item => (
|
||||
{scheduledItems.slice(0, Math.max(0, 5 - publishedOnDate.length)).map(item => (
|
||||
<CalendarItemTooltip
|
||||
key={item.id}
|
||||
title={item.title}
|
||||
@@ -608,20 +730,23 @@ export default function ContentCalendar() {
|
||||
onDragStart={!isPast ? (e) => handleDragStart(e, item, 'queue') : undefined}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={() => handleViewContent(item)}
|
||||
className={`text-xs p-1.5 rounded truncate transition-colors ${
|
||||
className={`text-xs p-1.5 rounded transition-colors break-words ${
|
||||
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}
|
||||
{truncateTitle(item.title, 7)}
|
||||
</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>
|
||||
{hasMoreThan5 && (
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className="text-xs p-1.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors w-full text-center font-medium"
|
||||
>
|
||||
View {totalItems - 5} more
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user