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:
24
backend/scripts/check_status.ts
Normal file
24
backend/scripts/check_status.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { createDirectus, rest, authentication, readField } from '@directus/sdk';
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, '../credentials.env') });
|
||||||
|
|
||||||
|
const DIRECTUS_URL = process.env.DIRECTUS_PUBLIC_URL || 'https://spark.jumpstartscaling.com';
|
||||||
|
const EMAIL = process.env.DIRECTUS_ADMIN_EMAIL;
|
||||||
|
const PASSWORD = process.env.DIRECTUS_ADMIN_PASSWORD;
|
||||||
|
|
||||||
|
const client = createDirectus(DIRECTUS_URL).with(authentication()).with(rest());
|
||||||
|
|
||||||
|
async function checkStatusField() {
|
||||||
|
await client.login(EMAIL!, PASSWORD!);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const field = await client.request(readField('generated_articles', 'status'));
|
||||||
|
console.log('Status Field Choices:', JSON.stringify(field.meta?.options?.choices, null, 2));
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Error reading status field:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkStatusField();
|
||||||
118
backend/scripts/setup_factory_schema.ts
Normal file
118
backend/scripts/setup_factory_schema.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { createDirectus, rest, authentication, createField, updateCollection, readCollections } from '@directus/sdk';
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, '../credentials.env') });
|
||||||
|
|
||||||
|
const DIRECTUS_URL = process.env.DIRECTUS_PUBLIC_URL || 'https://spark.jumpstartscaling.com';
|
||||||
|
const EMAIL = process.env.DIRECTUS_ADMIN_EMAIL;
|
||||||
|
const PASSWORD = process.env.DIRECTUS_ADMIN_PASSWORD;
|
||||||
|
|
||||||
|
const client = createDirectus(DIRECTUS_URL).with(authentication()).with(rest());
|
||||||
|
|
||||||
|
async function setupFactorySchema() {
|
||||||
|
console.log(`🚀 Connecting to Directus at ${DIRECTUS_URL}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`🔑 Authenticating as ${EMAIL}...`);
|
||||||
|
await client.login(EMAIL!, PASSWORD!);
|
||||||
|
console.log('✅ Authentication successful.');
|
||||||
|
|
||||||
|
// 1. Setup Kanban Status Field
|
||||||
|
console.log('\n--- Setting up Kanban Status ---');
|
||||||
|
try {
|
||||||
|
await client.request(createField('generated_articles', {
|
||||||
|
field: 'status',
|
||||||
|
type: 'string',
|
||||||
|
meta: {
|
||||||
|
interface: 'select-dropdown',
|
||||||
|
options: {
|
||||||
|
choices: [
|
||||||
|
{ text: 'Queued', value: 'queued', color: '#6366f1' }, // Indigo
|
||||||
|
{ text: 'Processing', value: 'processing', color: '#eab308' }, // Yellow
|
||||||
|
{ text: 'QC Review', value: 'qc', color: '#a855f7' }, // Purple
|
||||||
|
{ text: 'Approved', value: 'approved', color: '#22c55e' }, // Green
|
||||||
|
{ text: 'Published', value: 'published', color: '#10b981' } // Emerald
|
||||||
|
]
|
||||||
|
},
|
||||||
|
display: 'labels',
|
||||||
|
display_options: {
|
||||||
|
show_as_dot: true
|
||||||
|
},
|
||||||
|
note: 'Current stage in the content factory'
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
default_value: 'queued'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
console.log(' ✅ Field created: generated_articles.status');
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.errors?.[0]?.extensions?.code === 'FIELD_DUPLICATE') {
|
||||||
|
console.log(' ⏭️ Field exists: generated_articles.status');
|
||||||
|
} else {
|
||||||
|
console.log(' ❌ Error creating status field:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Add CRM Fields
|
||||||
|
console.log('\n--- Adding CRM Fields ---');
|
||||||
|
const crmFields = [
|
||||||
|
{ field: 'priority', type: 'string', note: 'Article priority', choices: [{ text: 'High', value: 'high' }, { text: 'Medium', value: 'medium' }, { text: 'Low', value: 'low' }] },
|
||||||
|
{ field: 'due_date', type: 'date', note: 'Target publication date' },
|
||||||
|
{ field: 'assignee', type: 'string', note: 'Team member responsible' },
|
||||||
|
{ field: 'seo_score', type: 'integer', note: 'Rankmath/Yoast score' },
|
||||||
|
{ field: 'notes', type: 'text', note: 'Internal notes' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const f of crmFields) {
|
||||||
|
try {
|
||||||
|
const meta: any = { note: f.note };
|
||||||
|
if (f.choices) {
|
||||||
|
meta.interface = 'select-dropdown';
|
||||||
|
meta.options = { choices: f.choices };
|
||||||
|
}
|
||||||
|
await client.request(createField('generated_articles', {
|
||||||
|
field: f.field,
|
||||||
|
type: f.type,
|
||||||
|
meta,
|
||||||
|
schema: {}
|
||||||
|
}));
|
||||||
|
console.log(` ✅ Field created: generated_articles.${f.field}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.errors?.[0]?.extensions?.code === 'FIELD_DUPLICATE') {
|
||||||
|
console.log(` ⏭️ Field exists: generated_articles.${f.field}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fix Visual Preview URL
|
||||||
|
console.log('\n--- Fixing Visual Preview Link ---');
|
||||||
|
// We need to update the collection metadata to point to our Astro frontend
|
||||||
|
// Note: The URL must be absolute or relative to the Directus root if served together.
|
||||||
|
// Since frontend is separate, we use the absolute URL of the deployed frontend.
|
||||||
|
// Assuming user acts as the frontend base safely.
|
||||||
|
|
||||||
|
// We'll set it to the Vercel/Coolify URL
|
||||||
|
const FRONTEND_URL = 'https://launch.jumpstartscaling.com';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.request(updateCollection('generated_articles', {
|
||||||
|
meta: {
|
||||||
|
preview_url: `${FRONTEND_URL}/preview/article/{id}`
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
console.log(` ✅ Updated preview_url to: ${FRONTEND_URL}/preview/article/{id}`);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.log(' ❌ Error updating collection metadata:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n✅ Factory Setup Complete!');
|
||||||
|
console.log('Your Directus "generated_articles" collection now supports Kanban & CRM features.');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupFactorySchema();
|
||||||
39
backend/scripts/update_status_choices.ts
Normal file
39
backend/scripts/update_status_choices.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { createDirectus, rest, authentication, updateField } from '@directus/sdk';
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, '../credentials.env') });
|
||||||
|
|
||||||
|
const DIRECTUS_URL = process.env.DIRECTUS_PUBLIC_URL || 'https://spark.jumpstartscaling.com';
|
||||||
|
const EMAIL = process.env.DIRECTUS_ADMIN_EMAIL;
|
||||||
|
const PASSWORD = process.env.DIRECTUS_ADMIN_PASSWORD;
|
||||||
|
|
||||||
|
const client = createDirectus(DIRECTUS_URL).with(authentication()).with(rest());
|
||||||
|
|
||||||
|
async function updateStatusChoices() {
|
||||||
|
await client.login(EMAIL!, PASSWORD!);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Updating status field choices for Kanban...');
|
||||||
|
await client.request(updateField('generated_articles', 'status', {
|
||||||
|
meta: {
|
||||||
|
options: {
|
||||||
|
choices: [
|
||||||
|
{ text: 'Queued', value: 'queued', color: '#6366f1' }, // Indigo
|
||||||
|
{ text: 'Processing', value: 'processing', color: '#eab308' }, // Yellow
|
||||||
|
{ text: 'QC Review', value: 'qc', color: '#a855f7' }, // Purple
|
||||||
|
{ text: 'Approved', value: 'approved', color: '#22c55e' }, // Green
|
||||||
|
{ text: 'Published', value: 'published', color: '#10b981' }, // Emerald
|
||||||
|
{ text: 'Draft', value: 'draft', color: '#94a3b8' }, // Legacy/Grey
|
||||||
|
{ text: 'Archived', value: 'archived', color: '#475569' } // Legacy/Slate
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
console.log('✅ Status choices updated successfully!');
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('❌ Error updating status field:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatusChoices();
|
||||||
@@ -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