feat: Factory Command Center dashboard with War Map, Module Flow Builder, Directus hook, and admin APIs
This commit is contained in:
209
directus/extensions/hooks/seo-factory-worker/index.js
Normal file
209
directus/extensions/hooks/seo-factory-worker/index.js
Normal 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
7975
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
},
|
||||
|
||||
372
frontend/src/components/factory/ModuleFlow.tsx
Normal file
372
frontend/src/components/factory/ModuleFlow.tsx
Normal 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;
|
||||
254
frontend/src/components/factory/WarMap.tsx
Normal file
254
frontend/src/components/factory/WarMap.tsx
Normal 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;
|
||||
794
frontend/src/pages/admin/factory.astro
Normal file
794
frontend/src/pages/admin/factory.astro
Normal 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">×</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>
|
||||
51
frontend/src/pages/api/admin/campaigns.ts
Normal file
51
frontend/src/pages/api/admin/campaigns.ts
Normal 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' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
45
frontend/src/pages/api/admin/queues.ts
Normal file
45
frontend/src/pages/api/admin/queues.ts
Normal 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' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
67
frontend/src/pages/api/admin/worklog.ts
Normal file
67
frontend/src/pages/api/admin/worklog.ts
Normal 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';
|
||||
}
|
||||
59
frontend/src/pages/api/seo/stats.ts
Normal file
59
frontend/src/pages/api/seo/stats.ts
Normal 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' } }
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user