Phase 1 missing file
This commit is contained in:
452
frontend/src/pages/Sites/PublishingQueue.tsx
Normal file
452
frontend/src/pages/Sites/PublishingQueue.tsx
Normal file
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* Publishing Queue Page
|
||||
* Shows scheduled content for publishing to external site
|
||||
* Allows reordering, pausing, and viewing calendar
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, 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 { fetchContent, Content } from '../../services/api';
|
||||
import {
|
||||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
ArrowRightIcon,
|
||||
CalendarIcon,
|
||||
ListIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
TrashBinIcon,
|
||||
EyeIcon,
|
||||
} from '../../icons';
|
||||
|
||||
type ViewMode = 'list' | 'calendar';
|
||||
|
||||
interface QueueItem extends Content {
|
||||
isPaused?: boolean;
|
||||
}
|
||||
|
||||
export default function PublishingQueue() {
|
||||
const { id: siteId } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [queueItems, setQueueItems] = useState<QueueItem[]>([]);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
const [draggedItem, setDraggedItem] = useState<QueueItem | null>(null);
|
||||
const [stats, setStats] = useState({
|
||||
scheduled: 0,
|
||||
publishing: 0,
|
||||
published: 0,
|
||||
failed: 0,
|
||||
});
|
||||
|
||||
const loadQueue = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Fetch content that is scheduled or publishing
|
||||
const response = await fetchContent({
|
||||
site_id: Number(siteId),
|
||||
page_size: 100,
|
||||
});
|
||||
|
||||
const items = (response.results || []).filter(
|
||||
(c: Content) => c.site_status === 'scheduled' || c.site_status === 'publishing'
|
||||
);
|
||||
|
||||
// Sort by scheduled_publish_at
|
||||
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;
|
||||
return dateA - dateB;
|
||||
});
|
||||
|
||||
setQueueItems(items);
|
||||
|
||||
// Calculate stats
|
||||
const allContent = response.results || [];
|
||||
setStats({
|
||||
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,
|
||||
failed: allContent.filter((c: Content) => c.site_status === 'failed').length,
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to load queue: ${error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [siteId, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (siteId) {
|
||||
loadQueue();
|
||||
}
|
||||
}, [siteId, loadQueue]);
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleDragStart = (e: React.DragEvent, item: QueueItem) => {
|
||||
setDraggedItem(item);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, targetItem: QueueItem) => {
|
||||
e.preventDefault();
|
||||
if (!draggedItem || draggedItem.id === targetItem.id) return;
|
||||
|
||||
const newItems = [...queueItems];
|
||||
const draggedIndex = newItems.findIndex(item => item.id === draggedItem.id);
|
||||
const targetIndex = newItems.findIndex(item => item.id === targetItem.id);
|
||||
|
||||
// Remove dragged item and insert at target position
|
||||
newItems.splice(draggedIndex, 1);
|
||||
newItems.splice(targetIndex, 0, draggedItem);
|
||||
|
||||
setQueueItems(newItems);
|
||||
setDraggedItem(null);
|
||||
|
||||
// TODO: Call API to update scheduled_publish_at based on new order
|
||||
toast.success('Queue order updated');
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedItem(null);
|
||||
};
|
||||
|
||||
const handlePauseItem = (item: QueueItem) => {
|
||||
// Toggle pause state (in real implementation, this would call an API)
|
||||
setQueueItems(prev =>
|
||||
prev.map(i => i.id === item.id ? { ...i, isPaused: !i.isPaused } : i)
|
||||
);
|
||||
toast.info(item.isPaused ? 'Item resumed' : 'Item paused');
|
||||
};
|
||||
|
||||
const handleRemoveFromQueue = (item: QueueItem) => {
|
||||
// TODO: Call API to set site_status back to 'not_published'
|
||||
setQueueItems(prev => prev.filter(i => i.id !== item.id));
|
||||
toast.success('Removed from queue');
|
||||
};
|
||||
|
||||
const handleViewContent = (item: QueueItem) => {
|
||||
navigate(`/sites/${siteId}/posts/${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: QueueItem) => {
|
||||
if (item.isPaused) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
<PauseIcon className="w-3 h-3" />
|
||||
Paused
|
||||
</span>
|
||||
);
|
||||
}
|
||||
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 = [];
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const date = new Date(today);
|
||||
date.setDate(today.getDate() + i);
|
||||
days.push(date);
|
||||
}
|
||||
return days;
|
||||
};
|
||||
|
||||
const getItemsForDate = (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()
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Publishing Queue" description="View and manage scheduled content" />
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Loading queue...</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageMeta title="Publishing Queue" description="View and manage scheduled content" />
|
||||
<PageHeader
|
||||
title="Publishing Queue"
|
||||
badge={{ icon: <ClockIcon />, color: 'amber' }}
|
||||
breadcrumb="Sites / Publishing Queue"
|
||||
/>
|
||||
|
||||
{/* Stats Overview */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 rounded-lg bg-warning-100 dark:bg-warning-900/30 flex items-center justify-center">
|
||||
<ClockIcon className="w-5 h-5 text-warning-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stats.scheduled}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Scheduled</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 rounded-lg bg-brand-100 dark:bg-brand-900/30 flex items-center justify-center">
|
||||
<ArrowRightIcon className="w-5 h-5 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stats.publishing}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Publishing</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 rounded-lg bg-success-100 dark:bg-success-900/30 flex items-center justify-center">
|
||||
<CheckCircleIcon className="w-5 h-5 text-success-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stats.published}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Published</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-10 rounded-lg bg-error-100 dark:bg-error-900/30 flex items-center justify-center">
|
||||
<TrashBinIcon className="w-5 h-5 text-error-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{stats.failed}</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Failed</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* View Toggle */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{queueItems.length} items in queue
|
||||
</h2>
|
||||
<ButtonGroup>
|
||||
<ButtonGroupItem
|
||||
isActive={viewMode === 'list'}
|
||||
onClick={() => setViewMode('list')}
|
||||
startIcon={<ListIcon className="w-4 h-4" />}
|
||||
>
|
||||
List
|
||||
</ButtonGroupItem>
|
||||
<ButtonGroupItem
|
||||
isActive={viewMode === 'calendar'}
|
||||
onClick={() => setViewMode('calendar')}
|
||||
startIcon={<CalendarIcon className="w-4 h-4" />}
|
||||
>
|
||||
Calendar
|
||||
</ButtonGroupItem>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
|
||||
{/* Queue Content */}
|
||||
{queueItems.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<ClockIcon 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 scheduled
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Content will appear here when it's scheduled for publishing.
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => navigate(`/sites/${siteId}/settings?tab=publishing`)}>
|
||||
Configure Publishing Settings
|
||||
</Button>
|
||||
</Card>
|
||||
) : viewMode === 'list' ? (
|
||||
/* List View */
|
||||
<ComponentCard title="Queue" desc="Drag items to reorder. Content publishes in order from top to bottom.">
|
||||
<div className="space-y-2">
|
||||
{queueItems.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, item)}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(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'}
|
||||
${item.isPaused ? 'opacity-60' : ''}
|
||||
hover:border-brand-300 dark:hover:border-brand-700 transition-all cursor-move
|
||||
`}
|
||||
>
|
||||
{/* Order number */}
|
||||
<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>
|
||||
|
||||
{/* Content info */}
|
||||
<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>
|
||||
<span className="text-gray-300 dark:text-gray-600">•</span>
|
||||
<span>{item.content_type}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status badge */}
|
||||
{getStatusBadge(item)}
|
||||
|
||||
{/* Actions */}
|
||||
<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={item.isPaused ? <PlayIcon className="w-4 h-4" /> : <PauseIcon className="w-4 h-4" />}
|
||||
variant="ghost"
|
||||
tone="neutral"
|
||||
size="sm"
|
||||
onClick={() => handlePauseItem(item)}
|
||||
title={item.isPaused ? 'Resume' : 'Pause'}
|
||||
/>
|
||||
<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 */
|
||||
<ComponentCard title="Calendar View" desc="Content scheduled for the next 14 days">
|
||||
<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 dayItems = getItemsForDate(date);
|
||||
const isToday = date.toDateString() === new Date().toDateString();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`
|
||||
min-h-[100px] p-2 rounded-lg border
|
||||
${isToday
|
||||
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className={`text-sm font-medium mb-1 ${isToday ? 'text-brand-600' : 'text-gray-600 dark:text-gray-400'}`}>
|
||||
{date.getDate()}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{dayItems.slice(0, 3).map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => handleViewContent(item)}
|
||||
className="text-xs p-1 bg-warning-100 dark:bg-warning-900/30 text-warning-800 dark:text-warning-200 rounded truncate cursor-pointer hover:bg-warning-200 dark:hover:bg-warning-900/50"
|
||||
title={item.title}
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
))}
|
||||
{dayItems.length > 3 && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
+{dayItems.length - 3} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-6 flex justify-between">
|
||||
<Button variant="outline" onClick={() => navigate(`/sites/${siteId}`)}>
|
||||
Back to Site
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => navigate(`/sites/${siteId}/settings?tab=publishing`)}>
|
||||
Publishing Settings
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user