feat: Factory Command Center dashboard with War Map, Module Flow Builder, Directus hook, and admin APIs

This commit is contained in:
cawcenter
2025-12-12 12:01:17 -05:00
parent 48bf7778e9
commit ebff81f001
10 changed files with 9833 additions and 1 deletions

View File

@@ -0,0 +1,209 @@
/**
* SPARK AI FACTORY - DIRECTUS HOOK
*
* Triggers automatically when production_queue items are updated.
* Processes content generation in chunks to avoid timeouts.
*/
module.exports = function registerHook({ filter, action }, { services, database, getSchema, logger }) {
const { ItemsService } = services;
const CHUNK_SIZE = 50; // Process 50 articles per trigger
// TRIGGER: When Queue status changes to "running"
action('production_queue.items.update', async (input, { keys, schema }) => {
if (input.status !== 'running') return;
const queueId = keys[0];
const currentSchema = await getSchema();
const queueService = new ItemsService('production_queue', { schema: currentSchema, knex: database });
const articleService = new ItemsService('generated_articles', { schema: currentSchema, knex: database });
const moduleService = new ItemsService('content_modules', { schema: currentSchema, knex: database });
const workLogService = new ItemsService('work_log', { schema: currentSchema, knex: database });
const locationsService = new ItemsService('locations_cities', { schema: currentSchema, knex: database });
try {
// 1. FETCH JOB DATA
const job = await queueService.readOne(queueId, {
fields: ['*', 'campaign.*']
});
logger.info(`[FACTORY] Starting Job: ${job.id}`);
// 2. GET SCHEDULE DATA
const scheduleData = job.schedule_data || [];
const startIndex = job.completed_count || 0;
const endIndex = Math.min(startIndex + CHUNK_SIZE, scheduleData.length);
if (startIndex >= scheduleData.length) {
// All done!
await queueService.updateOne(queueId, {
status: 'done',
completed_at: new Date().toISOString()
});
logger.info(`[FACTORY] Job ${queueId} COMPLETE`);
return;
}
// 3. FETCH LOCATIONS
const locationFilter = job.campaign?.target_locations_filter || {};
const locations = await locationsService.readByQuery({
filter: locationFilter,
limit: CHUNK_SIZE,
offset: startIndex
});
// 4. GET CONTENT RECIPE
const recipe = job.campaign?.content_recipe || ['intro', 'benefits', 'howto', 'conclusion'];
// 5. PRODUCTION LOOP
let generated = 0;
for (let i = 0; i < Math.min(locations.length, endIndex - startIndex); i++) {
const schedule = scheduleData[startIndex + i];
const city = locations[i];
if (!city || !schedule) continue;
const publishDate = new Date(schedule.publish_date);
const modifiedDate = new Date(schedule.modified_date);
// ASSEMBLE FROM MODULES
let finalHTML = '';
const usedModules = [];
for (const moduleType of recipe) {
const modules = await moduleService.readByQuery({
filter: {
site: { _eq: job.site },
module_type: { _eq: moduleType },
is_active: { _eq: true }
},
sort: ['usage_count'],
limit: 1
});
if (modules.length > 0) {
const mod = modules[0];
// SPIN CONTENT with context
const spunText = processSpintax(mod.content_spintax || '', {
city: city.city || city.name || '',
state: city.state || '',
county: city.county || '',
year: publishDate.getFullYear()
});
finalHTML += spunText + '\n\n';
usedModules.push(mod.id);
// Increment usage count
await database('content_modules')
.where('id', mod.id)
.increment('usage_count', 1);
}
}
// Generate headline
const headline = processSpintax(
job.campaign?.spintax_title || `{City} {State} Guide`,
{ city: city.city || '', state: city.state || '', year: publishDate.getFullYear() }
);
// CREATE ARTICLE
await articleService.createOne({
site: job.site,
campaign: job.campaign?.id,
headline: headline,
meta_title: headline.substring(0, 60),
meta_description: stripHtml(finalHTML).substring(0, 155) + '...',
full_html_body: finalHTML,
word_count: stripHtml(finalHTML).split(/\s+/).length,
is_published: true,
is_test_batch: false,
date_published: publishDate.toISOString(),
date_modified: modifiedDate.toISOString(),
sitemap_status: 'ghost',
location_city: city.city || city.name,
location_county: city.county,
location_state: city.state,
modules_used: usedModules
});
generated++;
}
// 6. UPDATE PROGRESS
const newCompleted = startIndex + generated;
const isComplete = newCompleted >= scheduleData.length;
await queueService.updateOne(queueId, {
completed_count: newCompleted,
status: isComplete ? 'done' : 'running',
completed_at: isComplete ? new Date().toISOString() : null
});
// Log progress
await workLogService.createOne({
site: job.site,
action: 'chunk_processed',
entity_type: 'production_queue',
entity_id: queueId,
details: {
generated,
progress: `${newCompleted}/${scheduleData.length}`,
chunk: Math.floor(startIndex / CHUNK_SIZE) + 1
}
});
logger.info(`[FACTORY] Chunk done: ${newCompleted}/${scheduleData.length}`);
// 7. TRIGGER NEXT CHUNK (if not complete)
if (!isComplete) {
// Re-trigger by updating status
setTimeout(async () => {
await queueService.updateOne(queueId, { status: 'running' });
}, 1000);
}
} catch (error) {
logger.error(`[FACTORY ERROR] ${error.message}`);
await queueService.updateOne(queueId, {
status: 'failed',
error_log: error.message
});
}
});
};
// --- HELPER FUNCTIONS ---
function processSpintax(text, context) {
// 1. Replace context variables
let output = text
.replace(/\{City\}/gi, context.city || '')
.replace(/\{State\}/gi, context.state || '')
.replace(/\{County\}/gi, context.county || '')
.replace(/\{Current_Year\}/gi, String(context.year || new Date().getFullYear()))
.replace(/\{Next_Year\}/gi, String((context.year || new Date().getFullYear()) + 1))
.replace(/\{Last_Year\}/gi, String((context.year || new Date().getFullYear()) - 1));
// 2. Resolve spintax {A|B|C}
let iterations = 100;
while (output.includes('{') && iterations > 0) {
output = output.replace(/\{([^{}]+)\}/g, (match, options) => {
const choices = options.split('|');
return choices[Math.floor(Math.random() * choices.length)];
});
iterations--;
}
return output;
}
function stripHtml(html) {
return (html || '')
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}

7975
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -21,14 +21,20 @@
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@tremor/react": "^3.18.7",
"astro": "^4.7.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.346.0",
"nanoid": "^5.0.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.2.1",
"react-flow-renderer": "^10.3.17",
"react-leaflet": "^5.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^2.6.0",
"tailwindcss": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
},

View File

@@ -0,0 +1,372 @@
/**
* Module Flow Builder Component
*
* Visual node-based editor for building content recipes.
* Users drag and connect module blocks to define article structure.
*/
import React, { useState, useCallback } from 'react';
interface ModuleNode {
id: string;
type: string;
label: string;
icon: string;
color: string;
x: number;
y: number;
}
interface Connection {
from: string;
to: string;
}
interface ModuleFlowProps {
onRecipeChange?: (recipe: string[]) => void;
}
const MODULE_TYPES = [
{ type: 'intro', label: 'Intro/Hook', icon: '🎯', color: '#8b5cf6' },
{ type: 'definition', label: 'Definition', icon: '📖', color: '#3b82f6' },
{ type: 'benefits', label: 'Benefits', icon: '✨', color: '#10b981' },
{ type: 'howto', label: 'How-To Steps', icon: '📝', color: '#f59e0b' },
{ type: 'comparison', label: 'Comparison', icon: '⚖️', color: '#ef4444' },
{ type: 'faq', label: 'FAQ', icon: '❓', color: '#06b6d4' },
{ type: 'conclusion', label: 'Conclusion/CTA', icon: '🚀', color: '#ec4899' },
];
export const ModuleFlow: React.FC<ModuleFlowProps> = ({ onRecipeChange }) => {
const [nodes, setNodes] = useState<ModuleNode[]>([]);
const [connections, setConnections] = useState<Connection[]>([]);
const [dragging, setDragging] = useState<string | null>(null);
const [connecting, setConnecting] = useState<string | null>(null);
const addNode = useCallback((type: typeof MODULE_TYPES[0]) => {
const newNode: ModuleNode = {
id: `node-${Date.now()}`,
type: type.type,
label: type.label,
icon: type.icon,
color: type.color,
x: 100 + (nodes.length * 50) % 300,
y: 100 + Math.floor(nodes.length / 3) * 100
};
const updatedNodes = [...nodes, newNode];
setNodes(updatedNodes);
updateRecipe(updatedNodes, connections);
}, [nodes, connections, onRecipeChange]);
const removeNode = useCallback((id: string) => {
const updatedNodes = nodes.filter(n => n.id !== id);
const updatedConnections = connections.filter(c => c.from !== id && c.to !== id);
setNodes(updatedNodes);
setConnections(updatedConnections);
updateRecipe(updatedNodes, updatedConnections);
}, [nodes, connections]);
const handleMouseDown = (id: string, e: React.MouseEvent) => {
if (e.shiftKey) {
setConnecting(id);
} else {
setDragging(id);
}
};
const handleMouseMove = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
if (!dragging) return;
const svg = e.currentTarget;
const rect = svg.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setNodes(nodes.map(n =>
n.id === dragging ? { ...n, x, y } : n
));
}, [dragging, nodes]);
const handleMouseUp = (targetId?: string) => {
if (connecting && targetId && connecting !== targetId) {
// Don't add duplicate connections
const exists = connections.some(c => c.from === connecting && c.to === targetId);
if (!exists) {
const updatedConnections = [...connections, { from: connecting, to: targetId }];
setConnections(updatedConnections);
updateRecipe(nodes, updatedConnections);
}
}
setDragging(null);
setConnecting(null);
};
const updateRecipe = (currentNodes: ModuleNode[], currentConnections: Connection[]) => {
// Build recipe from connected flow
// Start from nodes that have no incoming connections
const hasIncoming = new Set(currentConnections.map(c => c.to));
const startNodes = currentNodes.filter(n => !hasIncoming.has(n.id));
const visited = new Set<string>();
const recipe: string[] = [];
const traverse = (nodeId: string) => {
if (visited.has(nodeId)) return;
visited.add(nodeId);
const node = currentNodes.find(n => n.id === nodeId);
if (node) {
recipe.push(node.type);
currentConnections
.filter(c => c.from === nodeId)
.forEach(c => traverse(c.to));
}
};
startNodes.forEach(n => traverse(n.id));
// Add any unconnected nodes
currentNodes.forEach(n => {
if (!visited.has(n.id)) {
recipe.push(n.type);
}
});
onRecipeChange?.(recipe);
};
return (
<div className="module-flow-container">
{/* Palette */}
<div className="module-palette">
<h4>📦 Content Modules</h4>
<p className="palette-hint">Click to add, Shift+Drag to connect</p>
<div className="palette-items">
{MODULE_TYPES.map(type => (
<button
key={type.type}
className="palette-item"
style={{ borderColor: type.color }}
onClick={() => addNode(type)}
>
<span className="item-icon">{type.icon}</span>
<span className="item-label">{type.label}</span>
</button>
))}
</div>
</div>
{/* Canvas */}
<div className="flow-canvas-container">
<svg
className="flow-canvas"
onMouseMove={handleMouseMove}
onMouseUp={() => handleMouseUp()}
onMouseLeave={() => handleMouseUp()}
>
{/* Connection lines */}
{connections.map((conn, i) => {
const from = nodes.find(n => n.id === conn.from);
const to = nodes.find(n => n.id === conn.to);
if (!from || !to) return null;
return (
<line
key={i}
x1={from.x + 60}
y1={from.y + 30}
x2={to.x}
y2={to.y + 30}
stroke="#3b82f6"
strokeWidth={2}
markerEnd="url(#arrowhead)"
/>
);
})}
{/* Arrow marker */}
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon points="0 0, 10 3.5, 0 7" fill="#3b82f6" />
</marker>
</defs>
{/* Nodes */}
{nodes.map(node => (
<g
key={node.id}
transform={`translate(${node.x}, ${node.y})`}
onMouseDown={(e) => handleMouseDown(node.id, e)}
onMouseUp={() => handleMouseUp(node.id)}
style={{ cursor: 'grab' }}
>
<rect
width={120}
height={60}
rx={8}
fill="#1a1a2e"
stroke={node.color}
strokeWidth={2}
/>
<text x={15} y={25} fontSize={18}>{node.icon}</text>
<text x={40} y={28} fill="#fff" fontSize={12}>{node.label}</text>
<text
x={108}
y={18}
fill="#888"
fontSize={14}
onClick={(e) => { e.stopPropagation(); removeNode(node.id); }}
style={{ cursor: 'pointer' }}
>×</text>
<text x={15} y={48} fill="#666" fontSize={10}>
{node.type}
</text>
</g>
))}
{nodes.length === 0 && (
<text x="50%" y="50%" textAnchor="middle" fill="#666">
Click modules from the palette to add them here
</text>
)}
</svg>
</div>
{/* Recipe Preview */}
<div className="recipe-preview">
<h4>📜 Current Recipe</h4>
<div className="recipe-items">
{nodes.length === 0 ? (
<span className="empty">No modules added</span>
) : (
nodes.map((n, i) => (
<span key={n.id} className="recipe-item" style={{ background: n.color }}>
{n.icon} {n.type}
{i < nodes.length - 1 && <span className="arrow"></span>}
</span>
))
)}
</div>
</div>
<style>{`
.module-flow-container {
display: grid;
grid-template-columns: 200px 1fr;
grid-template-rows: 1fr auto;
gap: 16px;
height: 500px;
}
.module-palette {
background: rgba(0,0,0,0.3);
border-radius: 12px;
padding: 16px;
grid-row: 1 / 3;
}
.module-palette h4 {
color: #fff;
margin-bottom: 8px;
}
.palette-hint {
font-size: 0.75rem;
color: #666;
margin-bottom: 16px;
}
.palette-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.palette-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
color: #e0e0e0;
cursor: pointer;
transition: all 0.2s;
text-align: left;
}
.palette-item:hover {
background: rgba(255,255,255,0.08);
transform: translateX(4px);
}
.item-icon {
font-size: 1.2rem;
}
.item-label {
font-size: 0.85rem;
}
.flow-canvas-container {
background: rgba(0,0,0,0.3);
border-radius: 12px;
overflow: hidden;
}
.flow-canvas {
width: 100%;
height: 100%;
min-height: 400px;
}
.recipe-preview {
background: rgba(0,0,0,0.3);
border-radius: 12px;
padding: 16px;
}
.recipe-preview h4 {
color: #fff;
margin-bottom: 12px;
}
.recipe-items {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.recipe-item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border-radius: 16px;
font-size: 0.85rem;
color: #fff;
}
.recipe-item .arrow {
margin-left: 8px;
color: #666;
}
.empty {
color: #666;
font-style: italic;
}
`}</style>
</div>
);
};
export default ModuleFlow;

View File

@@ -0,0 +1,254 @@
/**
* USA Territory Map Component
*
* Interactive map showing campaign coverage by state.
* Uses React-Leaflet for rendering.
*/
import React, { useEffect, useState } from 'react';
interface StateData {
state: string;
code: string;
articleCount: number;
status: 'empty' | 'active' | 'saturated';
}
interface WarMapProps {
onStateClick?: (stateCode: string) => void;
}
// US State coordinates (simplified centroids)
const STATE_COORDS: Record<string, [number, number]> = {
'AL': [32.806671, -86.791130], 'AK': [61.370716, -152.404419],
'AZ': [33.729759, -111.431221], 'AR': [34.969704, -92.373123],
'CA': [36.116203, -119.681564], 'CO': [39.059811, -105.311104],
'CT': [41.597782, -72.755371], 'DE': [39.318523, -75.507141],
'FL': [27.766279, -81.686783], 'GA': [33.040619, -83.643074],
'HI': [21.094318, -157.498337], 'ID': [44.240459, -114.478828],
'IL': [40.349457, -88.986137], 'IN': [39.849426, -86.258278],
'IA': [42.011539, -93.210526], 'KS': [38.526600, -96.726486],
'KY': [37.668140, -84.670067], 'LA': [31.169546, -91.867805],
'ME': [44.693947, -69.381927], 'MD': [39.063946, -76.802101],
'MA': [42.230171, -71.530106], 'MI': [43.326618, -84.536095],
'MN': [45.694454, -93.900192], 'MS': [32.741646, -89.678696],
'MO': [38.456085, -92.288368], 'MT': [46.921925, -110.454353],
'NE': [41.125370, -98.268082], 'NV': [38.313515, -117.055374],
'NH': [43.452492, -71.563896], 'NJ': [40.298904, -74.521011],
'NM': [34.840515, -106.248482], 'NY': [42.165726, -74.948051],
'NC': [35.630066, -79.806419], 'ND': [47.528912, -99.784012],
'OH': [40.388783, -82.764915], 'OK': [35.565342, -96.928917],
'OR': [44.572021, -122.070938], 'PA': [40.590752, -77.209755],
'RI': [41.680893, -71.511780], 'SC': [33.856892, -80.945007],
'SD': [44.299782, -99.438828], 'TN': [35.747845, -86.692345],
'TX': [31.054487, -97.563461], 'UT': [40.150032, -111.862434],
'VT': [44.045876, -72.710686], 'VA': [37.769337, -78.169968],
'WA': [47.400902, -121.490494], 'WV': [38.491226, -80.954453],
'WI': [44.268543, -89.616508], 'WY': [42.755966, -107.302490]
};
const STATE_NAMES: Record<string, string> = {
'AL': 'Alabama', 'AK': 'Alaska', 'AZ': 'Arizona', 'AR': 'Arkansas',
'CA': 'California', 'CO': 'Colorado', 'CT': 'Connecticut', 'DE': 'Delaware',
'FL': 'Florida', 'GA': 'Georgia', 'HI': 'Hawaii', 'ID': 'Idaho',
'IL': 'Illinois', 'IN': 'Indiana', 'IA': 'Iowa', 'KS': 'Kansas',
'KY': 'Kentucky', 'LA': 'Louisiana', 'ME': 'Maine', 'MD': 'Maryland',
'MA': 'Massachusetts', 'MI': 'Michigan', 'MN': 'Minnesota', 'MS': 'Mississippi',
'MO': 'Missouri', 'MT': 'Montana', 'NE': 'Nebraska', 'NV': 'Nevada',
'NH': 'New Hampshire', 'NJ': 'New Jersey', 'NM': 'New Mexico', 'NY': 'New York',
'NC': 'North Carolina', 'ND': 'North Dakota', 'OH': 'Ohio', 'OK': 'Oklahoma',
'OR': 'Oregon', 'PA': 'Pennsylvania', 'RI': 'Rhode Island', 'SC': 'South Carolina',
'SD': 'South Dakota', 'TN': 'Tennessee', 'TX': 'Texas', 'UT': 'Utah',
'VT': 'Vermont', 'VA': 'Virginia', 'WA': 'Washington', 'WV': 'West Virginia',
'WI': 'Wisconsin', 'WY': 'Wyoming'
};
export const WarMap: React.FC<WarMapProps> = ({ onStateClick }) => {
const [stateData, setStateData] = useState<StateData[]>([]);
const [hoveredState, setHoveredState] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadStateData();
}, []);
const loadStateData = async () => {
try {
const response = await fetch('/api/seo/coverage-by-state');
const data = await response.json();
if (data.states) {
setStateData(data.states);
} else {
// Generate mock data for demo
const mockData = Object.entries(STATE_COORDS).map(([code]) => ({
state: STATE_NAMES[code] || code,
code,
articleCount: Math.floor(Math.random() * 500),
status: ['empty', 'active', 'saturated'][Math.floor(Math.random() * 3)] as any
}));
setStateData(mockData);
}
} catch (err) {
console.error('Failed to load state data:', err);
} finally {
setLoading(false);
}
};
const getStateColor = (status: string): string => {
switch (status) {
case 'saturated': return '#ef4444'; // Red
case 'active': return '#22c55e'; // Green
default: return '#6b7280'; // Gray
}
};
const getStateSize = (count: number): number => {
if (count > 200) return 24;
if (count > 100) return 20;
if (count > 50) return 16;
if (count > 0) return 12;
return 8;
};
if (loading) {
return (
<div className="war-map-loading">
<span>Loading territory map...</span>
</div>
);
}
return (
<div className="war-map-container">
<svg
viewBox="0 0 960 600"
className="war-map-svg"
style={{ width: '100%', height: '400px' }}
>
{/* Simplified US outline */}
<rect x="0" y="0" width="960" height="600" fill="#0a0a0f" />
{/* State markers */}
{stateData.map((state) => {
const coords = STATE_COORDS[state.code];
if (!coords) return null;
// Convert lat/lng to SVG coordinates (simplified)
const x = ((coords[1] + 125) / 60) * 900 + 30;
const y = ((50 - coords[0]) / 25) * 500 + 50;
const size = getStateSize(state.articleCount);
const color = getStateColor(state.status);
return (
<g
key={state.code}
className="state-marker"
onClick={() => onStateClick?.(state.code)}
onMouseEnter={() => setHoveredState(state.code)}
onMouseLeave={() => setHoveredState(null)}
style={{ cursor: 'pointer' }}
>
<circle
cx={x}
cy={y}
r={size}
fill={color}
opacity={hoveredState === state.code ? 1 : 0.7}
stroke={hoveredState === state.code ? '#fff' : 'transparent'}
strokeWidth={2}
/>
<text
x={x}
y={y + 4}
textAnchor="middle"
fill="#fff"
fontSize="10"
fontWeight="bold"
>
{state.code}
</text>
{hoveredState === state.code && (
<g>
<rect
x={x - 50}
y={y - 50}
width={100}
height={35}
rx={4}
fill="#1a1a2e"
stroke="#333"
/>
<text x={x} y={y - 35} textAnchor="middle" fill="#fff" fontSize="12">
{state.state}
</text>
<text x={x} y={y - 20} textAnchor="middle" fill="#888" fontSize="10">
{state.articleCount} articles
</text>
</g>
)}
</g>
);
})}
</svg>
{/* Legend */}
<div className="map-legend">
<div className="legend-item">
<span className="legend-dot" style={{ background: '#6b7280' }}></span>
<span>No Coverage</span>
</div>
<div className="legend-item">
<span className="legend-dot" style={{ background: '#22c55e' }}></span>
<span>Active Campaign</span>
</div>
<div className="legend-item">
<span className="legend-dot" style={{ background: '#ef4444' }}></span>
<span>Saturated</span>
</div>
</div>
<style>{`
.war-map-container {
background: rgba(0,0,0,0.3);
border-radius: 12px;
padding: 20px;
}
.war-map-loading {
display: flex;
align-items: center;
justify-content: center;
height: 400px;
color: #888;
}
.war-map-svg {
border-radius: 8px;
}
.state-marker {
transition: all 0.2s ease;
}
.map-legend {
display: flex;
justify-content: center;
gap: 24px;
margin-top: 16px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
color: #888;
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
`}</style>
</div>
);
};
export default WarMap;

View File

@@ -0,0 +1,794 @@
---
/**
* Factory Command Center Dashboard
*
* The main control panel for the SEO Content Factory.
* Uses React islands for interactive components.
*/
import Layout from '../../layouts/Layout.astro';
---
<Layout title="Factory Command Center">
<div class="factory-dashboard">
<!-- Header -->
<header class="dashboard-header">
<div class="header-content">
<h1>🏭 Factory Command Center</h1>
<div class="status-badge" id="factoryStatus">
<span class="pulse"></span>
<span>IDLE</span>
</div>
</div>
</header>
<!-- KPI Cards -->
<section class="kpi-grid" id="kpiCards">
<div class="kpi-card">
<div class="kpi-icon">📝</div>
<div class="kpi-content">
<span class="kpi-value" id="totalArticles">0</span>
<span class="kpi-label">Total Articles</span>
</div>
</div>
<div class="kpi-card ghost">
<div class="kpi-icon">👻</div>
<div class="kpi-content">
<span class="kpi-value" id="ghostCount">0</span>
<span class="kpi-label">Ghost (Hidden)</span>
</div>
</div>
<div class="kpi-card indexed">
<div class="kpi-icon">🔍</div>
<div class="kpi-content">
<span class="kpi-value" id="indexedCount">0</span>
<span class="kpi-label">Indexed (Live)</span>
</div>
</div>
<div class="kpi-card queue">
<div class="kpi-icon">⏳</div>
<div class="kpi-content">
<span class="kpi-value" id="queueCount">0</span>
<span class="kpi-label">In Queue</span>
</div>
</div>
</section>
<!-- Main Grid -->
<div class="main-grid">
<!-- Velocity Chart -->
<section class="panel velocity-panel">
<h2>📈 Velocity Distribution</h2>
<div class="chart-container">
<canvas id="velocityChart"></canvas>
</div>
<div class="chart-legend">
<span class="legend-item"><span class="dot ramp"></span> Ramp Up</span>
<span class="legend-item"><span class="dot weekend"></span> Weekend Throttle</span>
</div>
</section>
<!-- Active Campaigns -->
<section class="panel campaigns-panel">
<h2>🎯 Active Campaigns</h2>
<div class="campaign-list" id="campaignList">
<div class="loading-skeleton">Loading campaigns...</div>
</div>
</section>
<!-- Production Queue -->
<section class="panel queue-panel full-width">
<div class="panel-header">
<h2>⚙️ Production Queue</h2>
<button class="btn-primary" id="newJobBtn">+ New Job</button>
</div>
<div class="queue-table-container">
<table class="queue-table" id="queueTable">
<thead>
<tr>
<th>Status</th>
<th>Campaign</th>
<th>Progress</th>
<th>Velocity</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="queueTableBody">
<tr><td colspan="6" class="loading">Loading queue...</td></tr>
</tbody>
</table>
</div>
</section>
<!-- Work Log Terminal -->
<section class="panel terminal-panel full-width">
<div class="panel-header">
<h2>💻 Work Log</h2>
<button class="btn-ghost" id="clearLog">Clear</button>
</div>
<div class="terminal" id="terminal">
<div class="terminal-line system">[SYSTEM] Factory initialized. Ready for commands.</div>
</div>
</section>
</div>
<!-- Spintax Preview Modal -->
<div class="modal" id="spintaxModal">
<div class="modal-content">
<div class="modal-header">
<h3>✏️ Spintax Preview</h3>
<button class="close-btn" id="closeSpintax">&times;</button>
</div>
<div class="spintax-split">
<div class="spintax-input">
<label>Raw Spintax</label>
<textarea id="spintaxInput" placeholder="{Hello|Hi} {World|Friend}...">{The best {solar|renewable energy} {service|solution} in {City}, {State}.|{City} homeowners trust us for {premium|top-quality} {solar|clean energy} {installation|systems}.}</textarea>
</div>
<div class="spintax-output">
<label>Live Preview</label>
<div id="spintaxOutput" class="preview-box">Click "Spin" to preview...</div>
<button class="btn-primary" id="spinBtn">🎲 Spin</button>
</div>
</div>
</div>
</div>
<!-- Deploy Confirmation -->
<div class="modal" id="deployModal">
<div class="modal-content deploy-modal">
<h3>🚀 Deploy Campaign</h3>
<p>You are about to generate <strong id="deployCount">0</strong> articles.</p>
<div class="deploy-slider">
<div class="slider-track">
<div class="slider-thumb" id="deployThumb">
<span>→</span>
</div>
<span class="slider-label">Slide to Deploy</span>
</div>
</div>
<button class="btn-ghost" id="cancelDeploy">Cancel</button>
</div>
</div>
</div>
</Layout>
<style>
.factory-dashboard {
min-height: 100vh;
background: linear-gradient(135deg, #0a0a0f 0%, #1a1a2e 100%);
color: #e0e0e0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
padding: 20px;
}
.dashboard-header {
margin-bottom: 30px;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.dashboard-header h1 {
font-size: 2rem;
font-weight: 700;
background: linear-gradient(90deg, #fff, #888);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.status-badge {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 20px;
font-size: 0.85rem;
font-weight: 600;
}
.pulse {
width: 8px;
height: 8px;
background: #00ff88;
border-radius: 50%;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.2); }
}
/* KPI Cards */
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.kpi-card {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 16px;
padding: 24px;
display: flex;
align-items: center;
gap: 16px;
transition: all 0.3s ease;
}
.kpi-card:hover {
background: rgba(255,255,255,0.06);
transform: translateY(-2px);
}
.kpi-card.ghost { border-left: 3px solid #8b5cf6; }
.kpi-card.indexed { border-left: 3px solid #10b981; }
.kpi-card.queue { border-left: 3px solid #f59e0b; }
.kpi-icon {
font-size: 2rem;
}
.kpi-content {
display: flex;
flex-direction: column;
}
.kpi-value {
font-size: 2rem;
font-weight: 700;
color: #fff;
}
.kpi-label {
font-size: 0.85rem;
color: #888;
}
/* Main Grid */
.main-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.full-width {
grid-column: 1 / -1;
}
.panel {
background: rgba(255,255,255,0.02);
border: 1px solid rgba(255,255,255,0.06);
border-radius: 16px;
padding: 24px;
}
.panel h2 {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 20px;
color: #fff;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.panel-header h2 {
margin-bottom: 0;
}
/* Chart */
.chart-container {
height: 200px;
position: relative;
}
.chart-legend {
display: flex;
gap: 20px;
margin-top: 15px;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8rem;
color: #888;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.dot.ramp { background: #3b82f6; }
.dot.weekend { background: #ef4444; }
/* Campaign List */
.campaign-list {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 250px;
overflow-y: auto;
}
.campaign-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: rgba(255,255,255,0.03);
border-radius: 10px;
transition: background 0.2s;
}
.campaign-item:hover {
background: rgba(255,255,255,0.06);
}
.campaign-name {
font-weight: 500;
}
.campaign-count {
font-size: 0.85rem;
color: #888;
}
/* Queue Table */
.queue-table-container {
overflow-x: auto;
}
.queue-table {
width: 100%;
border-collapse: collapse;
}
.queue-table th,
.queue-table td {
padding: 12px 16px;
text-align: left;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.queue-table th {
color: #888;
font-weight: 500;
font-size: 0.85rem;
text-transform: uppercase;
}
.queue-table tr:hover td {
background: rgba(255,255,255,0.02);
}
.status-pill {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.status-pill.pending { background: #374151; color: #9ca3af; }
.status-pill.running { background: #1e3a5f; color: #60a5fa; }
.status-pill.done { background: #064e3b; color: #34d399; }
.status-pill.failed { background: #7f1d1d; color: #fca5a5; }
.progress-bar {
width: 100px;
height: 6px;
background: rgba(255,255,255,0.1);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
border-radius: 3px;
transition: width 0.3s ease;
}
/* Terminal */
.terminal {
background: #0d0d0d;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
padding: 16px;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.85rem;
height: 200px;
overflow-y: auto;
}
.terminal-line {
padding: 4px 0;
color: #888;
}
.terminal-line.system { color: #10b981; }
.terminal-line.error { color: #ef4444; }
.terminal-line.warning { color: #f59e0b; }
.terminal-line.info { color: #3b82f6; }
/* Buttons */
.btn-primary {
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.4);
}
.btn-ghost {
background: transparent;
color: #888;
border: 1px solid rgba(255,255,255,0.1);
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.btn-ghost:hover {
background: rgba(255,255,255,0.05);
color: #fff;
}
/* Modal */
.modal {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.8);
backdrop-filter: blur(4px);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: #1a1a2e;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 16px;
padding: 24px;
max-width: 800px;
width: 90%;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.close-btn {
background: none;
border: none;
color: #888;
font-size: 1.5rem;
cursor: pointer;
}
/* Spintax Preview */
.spintax-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.spintax-input textarea,
.preview-box {
width: 100%;
height: 200px;
background: #0d0d0d;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
padding: 12px;
color: #e0e0e0;
font-family: monospace;
font-size: 0.9rem;
resize: none;
}
.spintax-input label,
.spintax-output label {
display: block;
margin-bottom: 8px;
color: #888;
font-size: 0.85rem;
}
.spintax-output {
display: flex;
flex-direction: column;
}
.spintax-output .btn-primary {
margin-top: 12px;
align-self: flex-end;
}
/* Deploy Slider */
.deploy-modal {
text-align: center;
}
.deploy-slider {
margin: 30px 0;
}
.slider-track {
position: relative;
height: 60px;
background: linear-gradient(90deg, #1e3a5f, #3b82f6);
border-radius: 30px;
overflow: hidden;
}
.slider-thumb {
position: absolute;
left: 5px;
top: 5px;
width: 50px;
height: 50px;
background: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
cursor: grab;
transition: left 0.1s;
}
.slider-label {
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
color: rgba(255,255,255,0.7);
font-weight: 600;
}
.loading {
text-align: center;
color: #888;
padding: 40px;
}
</style>
<script>
// Initialize Dashboard
document.addEventListener('DOMContentLoaded', async () => {
await loadDashboardData();
initializeChart();
initializeSpintax();
initializeDeploySlider();
startPolling();
});
let chartInstance = null;
async function loadDashboardData() {
try {
// Load KPIs
const [articles, queues, campaigns] = await Promise.all([
fetch('/api/seo/stats').then(r => r.json()).catch(() => ({ total: 0, ghost: 0, indexed: 0 })),
fetch('/api/admin/queues').then(r => r.json()).catch(() => ({ queues: [] })),
fetch('/api/admin/campaigns').then(r => r.json()).catch(() => ({ campaigns: [] }))
]);
// Update KPIs
document.getElementById('totalArticles').textContent = articles.total || '0';
document.getElementById('ghostCount').textContent = articles.ghost || '0';
document.getElementById('indexedCount').textContent = articles.indexed || '0';
document.getElementById('queueCount').textContent = queues.queues?.filter(q => q.status === 'running').length || '0';
// Update campaigns list
const campaignList = document.getElementById('campaignList');
if (campaigns.campaigns?.length > 0) {
campaignList.innerHTML = campaigns.campaigns.map(c => `
<div class="campaign-item">
<span class="campaign-name">${c.name}</span>
<span class="campaign-count">${c.article_count || 0} articles</span>
</div>
`).join('');
} else {
campaignList.innerHTML = '<div class="loading">No active campaigns</div>';
}
// Update queue table
const queueBody = document.getElementById('queueTableBody');
if (queues.queues?.length > 0) {
queueBody.innerHTML = queues.queues.map(q => {
const progress = q.total_requested > 0 ? (q.completed_count / q.total_requested * 100).toFixed(0) : 0;
return `
<tr>
<td><span class="status-pill ${q.status}">${q.status}</span></td>
<td>${q.campaign_name || 'Unknown'}</td>
<td>
<div class="progress-bar"><div class="progress-fill" style="width: ${progress}%"></div></div>
<span style="font-size: 0.8rem; color: #888; margin-left: 8px;">${progress}%</span>
</td>
<td>${q.velocity_mode || 'STEADY'}</td>
<td>${new Date(q.date_created).toLocaleDateString()}</td>
<td>
<button class="btn-ghost" onclick="viewQueue('${q.id}')">View</button>
</td>
</tr>
`;
}).join('');
} else {
queueBody.innerHTML = '<tr><td colspan="6" class="loading">No jobs in queue</td></tr>';
}
} catch (err) {
console.error('Failed to load dashboard:', err);
addTerminalLine('[ERROR] Failed to load dashboard data', 'error');
}
}
function initializeChart() {
// Simple canvas chart (no external library needed)
const canvas = document.getElementById('velocityChart');
const ctx = canvas.getContext('2d');
canvas.width = canvas.parentElement.offsetWidth;
canvas.height = 200;
// Draw Gaussian curve
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ctx.beginPath();
for (let x = 0; x < canvas.width; x++) {
const progress = x / canvas.width;
// Ramp up curve: 0.2 to 1.0
const weight = 0.2 + (0.8 * progress);
// Add some noise
const noise = Math.random() * 0.1;
const y = canvas.height - ((weight + noise) * canvas.height * 0.8);
if (x === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
// Weekend dips
ctx.strokeStyle = '#ef4444';
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
for (let i = 0; i < 7; i++) {
const x = (canvas.width / 7) * i;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvas.height);
ctx.stroke();
}
}
function initializeSpintax() {
const spinBtn = document.getElementById('spinBtn');
const input = document.getElementById('spintaxInput');
const output = document.getElementById('spintaxOutput');
spinBtn.addEventListener('click', () => {
const text = input.value;
const spun = processSpintax(text, {
City: 'Orlando',
State: 'Florida',
Current_Year: '2024'
});
output.textContent = spun;
});
}
function processSpintax(text, context) {
let result = text
.replace(/\{City\}/gi, context.City)
.replace(/\{State\}/gi, context.State)
.replace(/\{Current_Year\}/gi, context.Current_Year);
let iterations = 50;
while (result.includes('{') && iterations > 0) {
result = result.replace(/\{([^{}]+)\}/g, (_, opts) => {
const choices = opts.split('|');
return choices[Math.floor(Math.random() * choices.length)];
});
iterations--;
}
return result;
}
function initializeDeploySlider() {
const thumb = document.getElementById('deployThumb');
const track = thumb.parentElement;
let isDragging = false;
thumb.addEventListener('mousedown', () => isDragging = true);
document.addEventListener('mouseup', () => {
if (isDragging) {
const rect = track.getBoundingClientRect();
const thumbPos = parseInt(thumb.style.left || '5');
if (thumbPos > rect.width * 0.8) {
// Deploy!
addTerminalLine('[SYSTEM] DEPLOYMENT INITIATED! 🚀', 'system');
document.getElementById('deployModal').classList.remove('active');
}
thumb.style.left = '5px';
}
isDragging = false;
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const rect = track.getBoundingClientRect();
let x = e.clientX - rect.left - 25;
x = Math.max(5, Math.min(x, rect.width - 55));
thumb.style.left = x + 'px';
});
}
function addTerminalLine(text, type = 'info') {
const terminal = document.getElementById('terminal');
const line = document.createElement('div');
line.className = `terminal-line ${type}`;
line.textContent = `[${new Date().toLocaleTimeString()}] ${text}`;
terminal.appendChild(line);
terminal.scrollTop = terminal.scrollHeight;
}
function startPolling() {
setInterval(() => {
loadDashboardData();
}, 10000); // Refresh every 10 seconds
}
// Modal handlers
document.getElementById('newJobBtn')?.addEventListener('click', () => {
document.getElementById('deployModal').classList.add('active');
});
document.getElementById('cancelDeploy')?.addEventListener('click', () => {
document.getElementById('deployModal').classList.remove('active');
});
document.getElementById('clearLog')?.addEventListener('click', () => {
document.getElementById('terminal').innerHTML = '<div class="terminal-line system">[SYSTEM] Log cleared.</div>';
});
window.viewQueue = function(id) {
addTerminalLine(`Viewing queue: ${id}`, 'info');
};
</script>

View File

@@ -0,0 +1,51 @@
// @ts-ignore
import type { APIRoute } from 'astro';
import { getDirectusClient, readItems } from '@/lib/directus/client';
/**
* Admin Campaigns API
* Returns all campaigns with article counts for dashboard.
*
* GET /api/admin/campaigns
*/
export const GET: APIRoute = async () => {
try {
const directus = getDirectusClient();
const campaigns = await directus.request(readItems('campaign_masters', {
fields: ['id', 'name', 'site', 'velocity_mode', 'test_batch_status', 'target_article_count', 'date_created'],
sort: ['-date_created'],
limit: 50
})) as any[];
// Get article counts per campaign
const enriched = await Promise.all(campaigns.map(async (c) => {
const articles = await directus.request(readItems('generated_articles', {
filter: { campaign: { _eq: c.id } },
aggregate: { count: ['id'] }
})) as any[];
return {
id: c.id,
name: c.name,
site: c.site,
velocity_mode: c.velocity_mode,
test_batch_status: c.test_batch_status,
target_count: c.target_article_count,
article_count: articles[0]?.count?.id || 0,
date_created: c.date_created
};
}));
return new Response(
JSON.stringify({ success: true, campaigns: enriched }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error getting campaigns:', error);
return new Response(
JSON.stringify({ error: 'Failed to get campaigns', campaigns: [] }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};

View File

@@ -0,0 +1,45 @@
// @ts-ignore
import type { APIRoute } from 'astro';
import { getDirectusClient, readItems } from '@/lib/directus/client';
/**
* Admin Queues API
* Returns all production queue items for dashboard.
*
* GET /api/admin/queues
*/
export const GET: APIRoute = async () => {
try {
const directus = getDirectusClient();
const queues = await directus.request(readItems('production_queue', {
fields: ['id', 'status', 'total_requested', 'completed_count', 'velocity_mode', 'date_created', 'campaign.name'],
sort: ['-date_created'],
limit: 50
})) as any[];
const formatted = queues.map(q => ({
id: q.id,
status: q.status,
total_requested: q.total_requested,
completed_count: q.completed_count,
velocity_mode: q.velocity_mode,
date_created: q.date_created,
campaign_name: q.campaign?.name || 'Unknown',
progress: q.total_requested > 0
? Math.round((q.completed_count / q.total_requested) * 100)
: 0
}));
return new Response(
JSON.stringify({ success: true, queues: formatted }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error getting queues:', error);
return new Response(
JSON.stringify({ error: 'Failed to get queues', queues: [] }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};

View File

@@ -0,0 +1,67 @@
// @ts-ignore
import type { APIRoute } from 'astro';
import { getDirectusClient, readItems } from '@/lib/directus/client';
/**
* Admin Work Log API
* Returns recent work log entries for terminal view.
*
* GET /api/admin/worklog?site_id={id}&limit=100
*/
export const GET: APIRoute = async ({ url }: { url: URL }) => {
try {
const siteId = url.searchParams.get('site_id');
const limit = parseInt(url.searchParams.get('limit') || '100', 10);
const directus = getDirectusClient();
const filter: any = {};
if (siteId) {
filter.site = { _eq: siteId };
}
const logs = await directus.request(readItems('work_log', {
filter,
sort: ['-date_created'],
limit,
fields: ['id', 'action', 'entity_type', 'entity_id', 'details', 'date_created']
})) as any[];
const formatted = logs.map(log => {
const time = new Date(log.date_created).toLocaleTimeString();
const action = log.action?.toUpperCase() || 'ACTION';
const details = typeof log.details === 'object'
? JSON.stringify(log.details)
: log.details || '';
return {
id: log.id,
timestamp: log.date_created,
formatted: `[${time}] [${action}] ${log.entity_type}: ${details}`,
type: getLogType(log.action)
};
});
return new Response(
JSON.stringify({ success: true, logs: formatted }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error getting work log:', error);
return new Response(
JSON.stringify({ error: 'Failed to get work log', logs: [] }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};
function getLogType(action: string): string {
const errorActions = ['failed', 'error', 'rejected'];
const warnActions = ['collision', 'duplicate', 'warning'];
const successActions = ['done', 'approved', 'indexed', 'published', 'generated'];
if (errorActions.some(a => action?.includes(a))) return 'error';
if (warnActions.some(a => action?.includes(a))) return 'warning';
if (successActions.some(a => action?.includes(a))) return 'system';
return 'info';
}

View File

@@ -0,0 +1,59 @@
// @ts-ignore
import type { APIRoute } from 'astro';
import { getDirectusClient, readItems, readItem } from '@/lib/directus/client';
/**
* SEO Stats API
* Returns article counts by status for dashboard KPIs.
*
* GET /api/seo/stats?site_id={id}
*/
export const GET: APIRoute = async ({ url }: { url: URL }) => {
try {
const siteId = url.searchParams.get('site_id');
const directus = getDirectusClient();
// Build filter
const filter: any = {};
if (siteId) {
filter.site = { _eq: siteId };
}
// Get all articles
const articles = await directus.request(readItems('generated_articles', {
filter,
fields: ['id', 'sitemap_status', 'is_published'],
limit: -1
})) as any[];
const total = articles.length;
const ghost = articles.filter(a => a.sitemap_status === 'ghost').length;
const indexed = articles.filter(a => a.sitemap_status === 'indexed').length;
const queued = articles.filter(a => a.sitemap_status === 'queued').length;
const published = articles.filter(a => a.is_published).length;
const draft = articles.filter(a => !a.is_published).length;
return new Response(
JSON.stringify({
success: true,
total,
ghost,
indexed,
queued,
published,
draft,
breakdown: {
sitemap: { ghost, indexed, queued },
publish: { published, draft }
}
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error getting stats:', error);
return new Response(
JSON.stringify({ error: 'Failed to get stats', total: 0, ghost: 0, indexed: 0 }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};