feat: Milestone 2 Task 1 - Content Factory Kanban Board
- Backend: Updated 'generated_articles' schema with Kanban status (Queued -> Published) and CRM fields - Backend: Fixed Directus Preview URL to point to valid Astro frontend - Frontend: Implemented full Kanban Board with Drag & Drop (@dnd-kit) - Frontend: Created Article Cards with priority, assignee, and status indicators - Frontend: Added /admin/factory/kanban page
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { FileText, Calendar, User, Eye, ArrowRight, MoreHorizontal } from 'lucide-react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface Article {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
status: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
due_date?: string;
|
||||
assignee?: string;
|
||||
date_created: string;
|
||||
}
|
||||
|
||||
interface ArticleCardProps {
|
||||
article: Article;
|
||||
onPreview: (id: string) => void;
|
||||
}
|
||||
|
||||
export const ArticleCard = ({ article, onPreview }: ArticleCardProps) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging
|
||||
} = useSortable({ id: article.id, data: { status: article.status } });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const getPriorityColor = (p: string) => {
|
||||
switch (p) {
|
||||
case 'high': return 'bg-red-500/10 text-red-500 border-red-500/20';
|
||||
case 'medium': return 'bg-yellow-500/10 text-yellow-500 border-yellow-500/20';
|
||||
case 'low': return 'bg-blue-500/10 text-blue-500 border-blue-500/20';
|
||||
default: return 'bg-zinc-500/10 text-zinc-500 border-zinc-500/20';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners} className="touch-none group">
|
||||
<Card className={cn(
|
||||
"bg-zinc-900 border-zinc-800 hover:border-zinc-700 transition-colors shadow-sm",
|
||||
isDragging && "opacity-50 ring-2 ring-blue-500"
|
||||
)}>
|
||||
<CardContent className="p-3 space-y-2">
|
||||
{/* Priority & Date */}
|
||||
<div className="flex justify-between items-start">
|
||||
<Badge variant="outline" className={cn("text-[10px] uppercase px-1.5 py-0 h-5", getPriorityColor(article.priority))}>
|
||||
{article.priority || 'medium'}
|
||||
</Badge>
|
||||
{article.due_date && (
|
||||
<div className="flex items-center text-[10px] text-zinc-500">
|
||||
<Calendar className="h-3 w-3 mr-1" />
|
||||
{new Date(article.due_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h4 className="text-sm font-medium text-zinc-200 line-clamp-2 leading-tight">
|
||||
{article.title}
|
||||
</h4>
|
||||
|
||||
{/* Footer Infos */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-zinc-800/50 mt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{article.assignee && (
|
||||
<div className="h-5 w-5 rounded-full bg-zinc-800 flex items-center justify-center text-[10px] text-zinc-400" title={article.assignee}>
|
||||
<User className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
<span className="text-[10px] text-zinc-600">
|
||||
{formatDistanceToNow(new Date(article.date_created), { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-zinc-400 hover:text-white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // prevent drag
|
||||
onPreview(article.id);
|
||||
}}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
closestCorners,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragStartEvent,
|
||||
DragOverEvent,
|
||||
DragEndEvent
|
||||
} from '@dnd-kit/core';
|
||||
import { arrayMove, sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getDirectusClient, readItems, updateItem } from '@/lib/directus/client';
|
||||
import { KanbanColumn } from './KanbanColumn';
|
||||
import { ArticleCard, Article } from './ArticleCard';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
const client = getDirectusClient();
|
||||
|
||||
const COLUMNS = [
|
||||
{ id: 'queued', title: 'Queued', color: 'bg-indigo-500' },
|
||||
{ id: 'processing', title: 'Processing', color: 'bg-yellow-500' },
|
||||
{ id: 'qc', title: 'QC Review', color: 'bg-purple-500' },
|
||||
{ id: 'approved', title: 'Approved', color: 'bg-green-500' },
|
||||
{ id: 'published', title: 'Published', color: 'bg-emerald-500' }
|
||||
];
|
||||
|
||||
export default function KanbanBoard() {
|
||||
const queryClient = useQueryClient();
|
||||
const [items, setItems] = useState<Article[]>([]);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
// 1. Fetch Data
|
||||
const { data: fetchedArticles, isLoading } = useQuery({
|
||||
queryKey: ['generated_articles_kanban'],
|
||||
queryFn: async () => {
|
||||
// @ts-ignore
|
||||
const res = await client.request(readItems('generated_articles', {
|
||||
limit: 100,
|
||||
sort: ['-date_created'],
|
||||
fields: ['*', 'status', 'priority', 'due_date', 'assignee']
|
||||
}));
|
||||
return res as unknown as Article[];
|
||||
}
|
||||
});
|
||||
|
||||
// Sync Query -> Local State
|
||||
useEffect(() => {
|
||||
if (fetchedArticles) {
|
||||
setItems(fetchedArticles);
|
||||
}
|
||||
}, [fetchedArticles]);
|
||||
|
||||
// 2. Mutation
|
||||
const updateStatusMutation = useMutation({
|
||||
mutationFn: async ({ id, status }: { id: string, status: string }) => {
|
||||
// @ts-ignore
|
||||
await client.request(updateItem('generated_articles', id, { status }));
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to move item');
|
||||
queryClient.invalidateQueries({ queryKey: ['generated_articles_kanban'] });
|
||||
}
|
||||
});
|
||||
|
||||
// 3. DnD Sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
);
|
||||
|
||||
// 4. Handlers
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const activeId = active.id;
|
||||
const overId = over.id;
|
||||
|
||||
if (activeId === overId) return;
|
||||
|
||||
const isActiveArticle = active.data.current?.sortable?.index !== undefined;
|
||||
const isOverArticle = over.data.current?.sortable?.index !== undefined;
|
||||
const isOverColumn = over.data.current?.type === 'column';
|
||||
|
||||
if (!isActiveArticle) return;
|
||||
|
||||
// Implements drag over changing column status
|
||||
const activeItem = items.find(i => i.id === activeId);
|
||||
const overItem = items.find(i => i.id === overId);
|
||||
|
||||
if (!activeItem) return;
|
||||
|
||||
// Moving between different columns
|
||||
if (activeItem && isOverColumn) {
|
||||
const overColumnId = over.id as string;
|
||||
if (activeItem.status !== overColumnId) {
|
||||
setItems((items) => {
|
||||
const activeIndex = items.findIndex((i) => i.id === activeId);
|
||||
const newItems = [...items];
|
||||
newItems[activeIndex] = { ...newItems[activeIndex], status: overColumnId };
|
||||
return newItems;
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (isActiveArticle && isOverArticle && activeItem.status !== overItem?.status) {
|
||||
const overColumnId = overItem?.status as string;
|
||||
setItems((items) => {
|
||||
const activeIndex = items.findIndex((i) => i.id === activeId);
|
||||
const newItems = [...items];
|
||||
newItems[activeIndex] = { ...newItems[activeIndex], status: overColumnId };
|
||||
// Also could reorder here if we had sorting field
|
||||
return arrayMove(newItems, activeIndex, activeIndex);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
|
||||
if (!over) return;
|
||||
|
||||
const activeItem = items.find(i => i.id === active.id);
|
||||
const overColumnId = (over.data.current?.type === 'column' ? over.id : items.find(i => i.id === over.id)?.status) as string;
|
||||
|
||||
if (activeItem && activeItem.status !== overColumnId && COLUMNS.some(c => c.id === overColumnId)) {
|
||||
// Persist change
|
||||
updateStatusMutation.mutate({ id: activeItem.id, status: overColumnId });
|
||||
toast.success(`Moved to ${COLUMNS.find(c => c.id === overColumnId)?.title}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreview = (id: string) => {
|
||||
window.open(`/preview/article/${id}`, '_blank');
|
||||
};
|
||||
|
||||
const activeItem = activeId ? items.find((i) => i.id === activeId) : null;
|
||||
|
||||
if (isLoading) return <div className="flex h-96 items-center justify-center text-zinc-500"><Loader2 className="animate-spin mr-2" /> Loading Board...</div>;
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="flex h-[calc(100vh-200px)] gap-4 overflow-x-auto pb-4">
|
||||
{COLUMNS.map((col) => (
|
||||
<div key={col.id} className="w-80 flex-shrink-0">
|
||||
<KanbanColumn
|
||||
id={col.id}
|
||||
title={col.title}
|
||||
color={col.color}
|
||||
articles={items.filter(i => i.status === col.id || (col.id === 'queued' && !['processing', 'qc', 'approved', 'published'].includes(i.status)))}
|
||||
onPreview={handlePreview}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeItem ? (
|
||||
<ArticleCard article={activeItem} onPreview={() => { }} />
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { ArticleCard, Article } from './ArticleCard';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface KanbanColumnProps {
|
||||
id: string;
|
||||
title: string;
|
||||
articles: Article[];
|
||||
color: string;
|
||||
onPreview: (id: string) => void;
|
||||
}
|
||||
|
||||
export const KanbanColumn = ({ id, title, articles, color, onPreview }: KanbanColumnProps) => {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: id,
|
||||
data: { type: 'column' }
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-zinc-950/30 rounded-lg p-2 border border-zinc-900/50">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn("w-2 h-2 rounded-full", color)} />
|
||||
<h3 className="font-semibold text-sm text-zinc-300 uppercase tracking-wider">{title}</h3>
|
||||
</div>
|
||||
<Badge variant="secondary" className="bg-zinc-900 text-zinc-500 rounded-sm px-1.5 h-5 text-xs font-mono">
|
||||
{articles.length}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"flex-1 space-y-3 overflow-y-auto pr-1 min-h-[150px] transition-colors rounded-md",
|
||||
isOver && "bg-zinc-900/50 ring-2 ring-zinc-800"
|
||||
)}
|
||||
>
|
||||
<SortableContext
|
||||
items={articles.map(a => a.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{articles.map((article) => (
|
||||
<ArticleCard
|
||||
key={article.id}
|
||||
article={article}
|
||||
onPreview={onPreview}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
||||
{articles.length === 0 && (
|
||||
<div className="h-24 flex items-center justify-center border-2 border-dashed border-zinc-800/50 rounded-lg">
|
||||
<span className="text-xs text-zinc-600">Drop here</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
33
frontend/src/pages/admin/factory/kanban.astro
Normal file
33
frontend/src/pages/admin/factory/kanban.astro
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
import KanbanBoard from '@/components/admin/factory/KanbanBoard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Plus } from 'lucide-react';
|
||||
---
|
||||
|
||||
<Layout title="Content Factory Board | Spark Intelligence">
|
||||
<div class="h-screen flex flex-col overflow-hidden">
|
||||
<div className="flex-none p-6 pb-2 flex justify-between items-center border-b border-zinc-800/50 bg-zinc-950/50 backdrop-blur-sm z-10">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white tracking-tight flex items-center gap-2">
|
||||
🏭 Content Factory
|
||||
<span className="text-xs font-normal text-zinc-500 bg-zinc-900 border border-zinc-800 px-2 py-0.5 rounded-full">Beta</span>
|
||||
</h1>
|
||||
<p className="text-zinc-400 text-sm mt-1">
|
||||
Drag and drop articles to move them through the production pipeline.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<a href="/admin/jumpstart/wizard">
|
||||
<Button className="bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 border-0">
|
||||
<Plus className="mr-2 h-4 w-4" /> New Article
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden p-6 bg-zinc-950">
|
||||
<KanbanBoard client:only="react" />
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
Reference in New Issue
Block a user