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,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();

View 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();

View 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();

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>