From 9ff5187e877f447eb45a6d9ee4ce1edd3aa8d7ec Mon Sep 17 00:00:00 2001 From: cawcenter Date: Sat, 13 Dec 2025 20:23:40 -0500 Subject: [PATCH] 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 --- backend/scripts/check_status.ts | 24 +++ backend/scripts/setup_factory_schema.ts | 118 ++++++++++++ backend/scripts/update_status_choices.ts | 39 ++++ .../components/admin/factory/ArticleCard.tsx | 107 +++++++++++ .../components/admin/factory/KanbanBoard.tsx | 179 ++++++++++++++++++ .../components/admin/factory/KanbanColumn.tsx | 64 +++++++ frontend/src/pages/admin/factory/kanban.astro | 33 ++++ 7 files changed, 564 insertions(+) create mode 100644 backend/scripts/check_status.ts create mode 100644 backend/scripts/setup_factory_schema.ts create mode 100644 backend/scripts/update_status_choices.ts create mode 100644 frontend/src/pages/admin/factory/kanban.astro diff --git a/backend/scripts/check_status.ts b/backend/scripts/check_status.ts new file mode 100644 index 0000000..5462186 --- /dev/null +++ b/backend/scripts/check_status.ts @@ -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(); diff --git a/backend/scripts/setup_factory_schema.ts b/backend/scripts/setup_factory_schema.ts new file mode 100644 index 0000000..287a419 --- /dev/null +++ b/backend/scripts/setup_factory_schema.ts @@ -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(); diff --git a/backend/scripts/update_status_choices.ts b/backend/scripts/update_status_choices.ts new file mode 100644 index 0000000..eacf0f6 --- /dev/null +++ b/backend/scripts/update_status_choices.ts @@ -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(); diff --git a/frontend/src/components/admin/factory/ArticleCard.tsx b/frontend/src/components/admin/factory/ArticleCard.tsx index e69de29..0a4d9b6 100644 --- a/frontend/src/components/admin/factory/ArticleCard.tsx +++ b/frontend/src/components/admin/factory/ArticleCard.tsx @@ -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 ( +
+ + + {/* Priority & Date */} +
+ + {article.priority || 'medium'} + + {article.due_date && ( +
+ + {new Date(article.due_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} +
+ )} +
+ + {/* Title */} +

+ {article.title} +

+ + {/* Footer Infos */} +
+
+ {article.assignee && ( +
+ +
+ )} + + {formatDistanceToNow(new Date(article.date_created), { addSuffix: true })} + +
+ +
+ +
+
+
+
+
+ ); +}; diff --git a/frontend/src/components/admin/factory/KanbanBoard.tsx b/frontend/src/components/admin/factory/KanbanBoard.tsx index e69de29..940b9a1 100644 --- a/frontend/src/components/admin/factory/KanbanBoard.tsx +++ b/frontend/src/components/admin/factory/KanbanBoard.tsx @@ -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([]); + const [activeId, setActiveId] = useState(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
Loading Board...
; + + return ( + +
+ {COLUMNS.map((col) => ( +
+ i.status === col.id || (col.id === 'queued' && !['processing', 'qc', 'approved', 'published'].includes(i.status)))} + onPreview={handlePreview} + /> +
+ ))} +
+ + + {activeItem ? ( + { }} /> + ) : null} + +
+ ); +} diff --git a/frontend/src/components/admin/factory/KanbanColumn.tsx b/frontend/src/components/admin/factory/KanbanColumn.tsx index e69de29..e6736bd 100644 --- a/frontend/src/components/admin/factory/KanbanColumn.tsx +++ b/frontend/src/components/admin/factory/KanbanColumn.tsx @@ -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 ( +
+ {/* Header */} +
+
+
+

{title}

+
+ + {articles.length} + +
+ + {/* List */} +
+ a.id)} + strategy={verticalListSortingStrategy} + > + {articles.map((article) => ( + + ))} + + + {articles.length === 0 && ( +
+ Drop here +
+ )} +
+
+ ); +}; diff --git a/frontend/src/pages/admin/factory/kanban.astro b/frontend/src/pages/admin/factory/kanban.astro new file mode 100644 index 0000000..8b0381c --- /dev/null +++ b/frontend/src/pages/admin/factory/kanban.astro @@ -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'; +--- + + +
+
+
+

+ šŸ­ Content Factory + Beta +

+

+ Drag and drop articles to move them through the production pipeline. +

+
+ +
+ +
+ +
+
+