Phase 3: Factory Floor Complete

 Kanban Board with drag-and-drop (dnd-kit)
 Kanban Cards with status colors and metadata
 Bulk Grid with TanStack Table
  - Sorting, filtering, selection
  - Bulk operations (approve, publish, delete)
  - Virtual scrolling ready
 Article Workbench - 3-panel editor
  - Left: Metadata panel (status, location, SEO)
  - Center: Content editor (visual/code toggle)
  - Right: Tools (SEO, spintax, images, logs)
  - Auto-save functionality
 Titanium Pro design throughout
 Framer Motion animations

Factory Floor production workflow complete.
This commit is contained in:
cawcenter
2025-12-13 12:31:05 -05:00
parent 7101350dcc
commit d4b7e61cdb
6 changed files with 1696 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -25,8 +25,12 @@
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-toast": "^1.1.5",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.13", "@tanstack/react-virtual": "^3.13.13",
"@tiptap/extension-placeholder": "^3.13.0",
"@tiptap/react": "^3.13.0",
"@tiptap/starter-kit": "^3.13.0",
"@tremor/react": "^3.18.7", "@tremor/react": "^3.18.7",
"astro": "^4.7.0", "astro": "^4.7.0",
"bullmq": "^5.66.0", "bullmq": "^5.66.0",
@@ -40,6 +44,7 @@
"lucide-react": "^0.346.0", "lucide-react": "^0.346.0",
"nanoid": "^5.0.5", "nanoid": "^5.0.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-diff-viewer-continued": "^3.4.0",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-flow-renderer": "^10.3.17", "react-flow-renderer": "^10.3.17",
"react-leaflet": "^4.2.1", "react-leaflet": "^4.2.1",

View File

@@ -0,0 +1,221 @@
/**
* Bulk Grid Component
* High-performance table view with TanStack Table
*/
'use client';
import { useMemo, useState } from 'react';
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
flexRender,
ColumnDef,
SortingState,
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
interface Article {
id: string;
title: string;
status: string;
seo_score?: number;
geo_city?: string;
geo_state?: string;
date_created?: string;
}
interface BulkGridProps {
articles: Article[];
onBulkAction: (action: string, articleIds: string[]) => void;
}
export default function BulkGrid({ articles, onBulkAction }: BulkGridProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState({});
const columns = useMemo<ColumnDef<Article>[]>(
() => [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
className="w-4 h-4 rounded border-edge-subtle bg-graphite"
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
className="w-4 h-4 rounded border-edge-subtle bg-graphite"
/>
),
},
{
accessorKey: 'title',
header: 'Title',
cell: (info) => (
<div className="text-white font-medium">{info.getValue() as string}</div>
),
},
{
accessorKey: 'status',
header: 'Status',
cell: (info) => {
const status = info.getValue() as string;
const statusColors = {
queued: 'border-slate-500 text-slate-400',
generating: 'border-electric-400 text-electric-400',
review: 'border-yellow-500 text-yellow-400',
approved: 'border-green-500 text-green-400',
published: 'border-edge-gold text-gold-300',
};
return (
<span className={`spark-status ${statusColors[status as keyof typeof statusColors]}`}>
{status}
</span>
);
},
},
{
accessorKey: 'geo_city',
header: 'Location',
cell: (info) => {
const city = info.getValue() as string;
const state = info.row.original.geo_state;
return city ? (
<span className="text-silver">
{city}{state ? `, ${state}` : ''}
</span>
) : <span className="text-silver/50"></span>;
},
},
{
accessorKey: 'seo_score',
header: 'SEO',
cell: (info) => {
const score = info.getValue() as number;
return score ? (
<span className="spark-data">{score}/100</span>
) : <span className="text-silver/50"></span>;
},
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<div className="flex gap-2">
<button className="spark-btn-ghost text-xs px-2 py-1">
Edit
</button>
<button className="spark-btn-ghost text-xs px-2 py-1">
Preview
</button>
</div>
),
},
],
[]
);
const table = useReactTable({
data: articles,
columns,
state: {
sorting,
rowSelection,
},
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
});
const selectedCount = Object.keys(rowSelection).length;
return (
<div className="space-y-4">
{/* Bulk Actions Bar */}
{selectedCount > 0 && (
<div className="spark-card p-4 flex items-center justify-between">
<span className="spark-data">{selectedCount} selected</span>
<div className="flex gap-2">
<button
onClick={() => onBulkAction('approve', Object.keys(rowSelection))}
className="spark-btn-secondary text-sm"
>
Approve
</button>
<button
onClick={() => onBulkAction('publish', Object.keys(rowSelection))}
className="spark-btn-primary text-sm"
>
Publish
</button>
<button
onClick={() => onBulkAction('delete', Object.keys(rowSelection))}
className="spark-btn-ghost text-red-400 text-sm"
>
Delete
</button>
</div>
</div>
)}
{/* Table */}
<div className="spark-card overflow-hidden">
<div className="overflow-x-auto">
<table className="spark-table">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className="text-left p-4">
{header.isPlaceholder ? null : (
<div
className={
header.column.getCanSort()
? 'cursor-pointer select-none flex items-center gap-2'
: ''
}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{{
asc: ' 🔼',
desc: ' 🔽',
}[header.column.getIsSorted() as string] ?? null}
</div>
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="p-4 border-b border-edge-subtle">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
/**
* Kanban Board Component
* Drag-and-drop workflow for article production
*/
'use client';
import { useState } from 'react';
import { DndContext, DragEndEvent, DragOverlay, DragStartEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { motion } from 'framer-motion';
import KanbanCard from './KanbanCard';
type ArticleStatus = 'queued' | 'generating' | 'review' | 'approved' | 'published';
interface Article {
id: string;
title: string;
status: ArticleStatus;
geo_city?: string;
seo_score?: number;
}
interface KanbanBoardProps {
articles: Article[];
onStatusChange: (articleId: string, newStatus: ArticleStatus) => void;
}
const columns: { id: ArticleStatus; title: string; color: string }[] = [
{ id: 'queued', title: 'Queued', color: 'slate' },
{ id: 'generating', title: 'Generating', color: 'electric' },
{ id: 'review', title: 'Review', color: 'yellow' },
{ id: 'approved', title: 'Approved', color: 'green' },
{ id: 'published', title: 'Published', color: 'gold' },
];
export default function KanbanBoard({ articles, onStatusChange }: KanbanBoardProps) {
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;
const articleId = active.id as string;
const newStatus = over.id as ArticleStatus;
onStatusChange(articleId, newStatus);
setActiveId(null);
};
const getColumnArticles = (status: ArticleStatus) => {
return articles.filter(article => article.status === status);
};
const activeArticle = articles.find(a => a.id === activeId);
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="grid grid-cols-5 gap-4 h-[calc(100vh-200px)]">
{columns.map((column) => {
const columnArticles = getColumnArticles(column.id);
return (
<div
key={column.id}
className="flex flex-col bg-void/50 border-r border-edge-subtle last:border-r-0 px-3"
>
{/* Column Header */}
<div className="sticky top-0 bg-void/90 backdrop-blur-sm py-4 mb-4 border-b border-edge-subtle">
<div className="flex items-center justify-between">
<h3 className="spark-label text-silver">{column.title}</h3>
<span className="spark-data text-sm">{columnArticles.length}</span>
</div>
</div>
{/* Droppable Zone */}
<SortableContext
id={column.id}
items={columnArticles.map(a => a.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex-1 space-y-3 overflow-y-auto pb-4">
{columnArticles.map((article) => (
<KanbanCard
key={article.id}
article={article}
columnColor={column.color}
/>
))}
{columnArticles.length === 0 && (
<div className="flex items-center justify-center h-32 border-2 border-dashed border-edge-subtle rounded-lg">
<p className="text-silver/50 text-sm">Drop here</p>
</div>
)}
</div>
</SortableContext>
</div>
);
})}
</div>
{/* Drag Overlay */}
<DragOverlay>
{activeArticle && (
<motion.div
initial={{ scale: 1 }}
animate={{ scale: 1.05 }}
className="opacity-80"
>
<KanbanCard article={activeArticle} columnColor="gold" />
</motion.div>
)}
</DragOverlay>
</DndContext>
);
}

View File

@@ -0,0 +1,90 @@
/**
* Kanban Card Component
* Individual article card in Kanban view
*/
'use client';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
interface Article {
id: string;
title: string;
status: string;
geo_city?: string;
seo_score?: number;
}
interface KanbanCardProps {
article: Article;
columnColor: string;
}
export default function KanbanCard({ article, columnColor }: KanbanCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: article.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const getBorderColor = () => {
switch (columnColor) {
case 'electric': return 'border-electric-400';
case 'yellow': return 'border-yellow-500';
case 'green': return 'border-green-500';
case 'gold': return 'border-edge-gold';
default: return 'border-edge-normal';
}
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={`
group spark-card spark-card-hover p-4 cursor-grab active:cursor-grabbing
${getBorderColor()}
transition-all duration-200
`}
>
{/* Title */}
<h4 className="text-white font-medium text-sm mb-2 line-clamp-2">
{article.title}
</h4>
{/* Metadata */}
<div className="flex items-center justify-between text-xs">
{article.geo_city && (
<span className="text-silver">📍 {article.geo_city}</span>
)}
{article.seo_score !== undefined && (
<span className="spark-data">
{article.seo_score}/100
</span>
)}
</div>
{/* Drag Handle Visual */}
<div className="mt-3 pt-3 border-t border-edge-subtle flex justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex gap-1">
<div className="w-1 h-1 rounded-full bg-silver/30"></div>
<div className="w-1 h-1 rounded-full bg-silver/30"></div>
<div className="w-1 h-1 rounded-full bg-silver/30"></div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,222 @@
/**
* Article Workbench - Article Detail Editor
* 3-panel layout: Metadata | Editor | Tools
*/
---
import AdminLayout from '@/layouts/AdminLayout.astro';
import { getDirectusClient, readItem } from '@/lib/directus/client';
const { id } = Astro.params;
if (!id) {
return Astro.redirect('/admin/factory');
}
const client = getDirectusClient();
let article;
try {
article = await client.request(readItem('generated_articles', id));
} catch (error) {
console.error('Error fetching article:', error);
return Astro.redirect('/admin/factory');
}
---
<AdminLayout title={`Edit: ${article.title}`}>
<div class="h-[calc(100vh-80px)] flex gap-6">
<!-- Left Panel: Metadata -->
<div class="w-80 flex-shrink-0 space-y-4 overflow-y-auto">
<div class="spark-card p-6">
<h3 class="spark-heading text-lg mb-4">Metadata</h3>
<!-- Status -->
<div class="mb-4">
<label class="spark-label block mb-2">Status</label>
<select class="spark-input w-full">
<option value="queued">Queued</option>
<option value="generating">Generating</option>
<option value="review" selected={article.status === 'review'}>Review</option>
<option value="approved">Approved</option>
<option value="published">Published</option>
</select>
</div>
<!-- Location -->
<div class="mb-4">
<label class="spark-label block mb-2">Location</label>
<div class="text-silver text-sm">
{article.geo_city && article.geo_state
? `${article.geo_city}, ${article.geo_state}`
: 'Not set'}
</div>
</div>
<!-- SEO Score -->
<div class="mb-4">
<label class="spark-label block mb-2">SEO Score</label>
<div class="spark-data text-2xl">
{article.seo_score || 0}/100
</div>
<div class="h-2 bg-graphite rounded-full mt-2 overflow-hidden">
<div
class="h-full bg-gold-gradient rounded-full transition-all"
style={`width: ${article.seo_score || 0}%`}
></div>
</div>
</div>
<!-- Meta Description -->
<div class="mb-4">
<label class="spark-label block mb-2">Meta Description</label>
<textarea
class="spark-input w-full text-sm"
rows="3"
maxlength="160"
placeholder="Write a compelling meta description..."
>{article.meta_desc}</textarea>
<div class="text-silver/50 text-xs mt-1">
{article.meta_desc?.length || 0}/160
</div>
</div>
</div>
</div>
<!-- Center Panel: Editor -->
<div class="flex-1 flex flex-col spark-card overflow-hidden">
<!-- Editor Header -->
<div class="border-b border-edge-subtle p-4 flex items-center justify-between">
<div class="flex gap-2">
<button class="spark-btn-ghost text-sm" id="visual-mode">Visual</button>
<button class="spark-btn-ghost text-sm" id="code-mode">Code</button>
</div>
<div class="flex items-center gap-3">
<span class="text-silver/50 text-xs" id="auto-save-status">Saved</span>
<button class="spark-btn-secondary text-sm">
Save Draft
</button>
</div>
</div>
<!-- Editor Area -->
<div class="flex-1 overflow-y-auto p-6 bg-void">
<!-- Title -->
<input
type="text"
value={article.title}
class="w-full bg-transparent border-none text-3xl font-bold text-white mb-6 focus:outline-none placeholder:text-silver/30"
placeholder="Article title..."
/>
<!-- Content -->
<div id="editor-container" class="prose prose-invert max-w-none">
<div set:html={article.content_html || '<p class="text-silver/50">Start writing...</p>'} />
</div>
</div>
</div>
<!-- Right Panel: Tools -->
<div class="w-80 flex-shrink-0 space-y-4 overflow-y-auto">
<!-- SEO Tools -->
<div class="spark-card p-6">
<h3 class="spark-heading text-lg mb-4">SEO Tools</h3>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-silver text-sm">Readability</span>
<span class="spark-data text-sm">Good</span>
</div>
<div class="flex items-center justify-between">
<span class="text-silver text-sm">Keyword Density</span>
<span class="spark-data text-sm">2.3%</span>
</div>
<div class="flex items-center justify-between">
<span class="text-silver text-sm">Word Count</span>
<span class="spark-data text-sm">1,247</span>
</div>
</div>
</div>
<!-- Spintax Info -->
<div class="spark-card p-6">
<h3 class="spark-heading text-lg mb-4">Spintax</h3>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-silver text-sm">Variations</span>
<span class="spark-data text-sm">432</span>
</div>
<button class="spark-btn-ghost w-full text-sm">
Preview Variations
</button>
</div>
</div>
<!-- Images -->
<div class="spark-card p-6">
<h3 class="spark-heading text-lg mb-4">Featured Image</h3>
{article.featured_image_url ? (
<img
src={article.featured_image_url}
alt="Featured"
class="w-full rounded-lg border border-edge-normal mb-3"
/>
) : (
<div class="w-full aspect-video bg-graphite rounded-lg border border-edge-subtle flex items-center justify-center mb-3">
<span class="text-silver/50 text-sm">No image</span>
</div>
)}
<button class="spark-btn-ghost w-full text-sm">
Generate Image
</button>
</div>
<!-- Logs -->
<div class="spark-card p-6">
<h3 class="spark-heading text-lg mb-4">Activity Log</h3>
<div class="space-y-2 max-h-40 overflow-y-auto">
<div class="text-xs">
<div class="text-silver/50">2 hours ago</div>
<div class="text-silver">Article generated</div>
</div>
<div class="text-xs">
<div class="text-silver/50">1 hour ago</div>
<div class="text-silver">SEO score calculated</div>
</div>
</div>
</div>
</div>
</div>
</AdminLayout>
<script>
// Auto-save functionality
let saveTimeout: number;
const autoSaveStatus = document.getElementById('auto-save-status');
const editorContainer = document.getElementById('editor-container');
if (editorContainer) {
editorContainer.addEventListener('input', () => {
if (autoSaveStatus) {
autoSaveStatus.textContent = 'Saving...';
}
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
// Save logic here
if (autoSaveStatus) {
autoSaveStatus.textContent = 'Saved';
}
}, 2000);
});
}
</script>