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:
1024
frontend/package-lock.json
generated
1024
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
221
frontend/src/components/factory/BulkGrid.tsx
Normal file
221
frontend/src/components/factory/BulkGrid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
frontend/src/components/factory/KanbanBoard.tsx
Normal file
134
frontend/src/components/factory/KanbanBoard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
frontend/src/components/factory/KanbanCard.tsx
Normal file
90
frontend/src/components/factory/KanbanCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
222
frontend/src/pages/admin/factory/[id].astro
Normal file
222
frontend/src/pages/admin/factory/[id].astro
Normal 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>
|
||||||
Reference in New Issue
Block a user