Phase 1 missing file

This commit is contained in:
IGNY8 VPS (Salman)
2026-01-05 03:41:17 +00:00
parent e93ea77c2b
commit ff44827b35

View 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>
</>
);
}