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-tabs": "^1.0.4",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@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",
|
||||
"astro": "^4.7.0",
|
||||
"bullmq": "^5.66.0",
|
||||
@@ -40,6 +44,7 @@
|
||||
"lucide-react": "^0.346.0",
|
||||
"nanoid": "^5.0.5",
|
||||
"react": "^18.3.1",
|
||||
"react-diff-viewer-continued": "^3.4.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-flow-renderer": "^10.3.17",
|
||||
"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