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:
cawcenter
2025-12-13 20:23:40 -05:00
parent 5aaef362b4
commit 9ff5187e87
7 changed files with 564 additions and 0 deletions

View File

@@ -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>
);
};

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View 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>