feat: Add God Mode Command Center dashboard
- Created unified admin dashboard at /admin/god-mode - Added search API endpoint for cross-collection queries - Added bulk actions API for publish/draft/archive/delete - Built 5 React components: - GodModeCommandCenter: Main dashboard with 4-tab interface - UnifiedSearchBar: Global search across all collections - BulkActionsToolbar: Bulk operations UI - ContentTable: Data table with selection and preview links - StatsPanel: Live statistics for all collections Features: - Search across sites, pages, posts, and articles - Bulk edit hundreds of items at once - Live stats with auto-refresh - Preview links for all content types - Status badges and metadata display
This commit is contained in:
122
frontend/src/components/admin/god/BulkActionsToolbar.tsx
Normal file
122
frontend/src/components/admin/god/BulkActionsToolbar.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useState } from 'react';
|
||||
import { CheckSquare, Square, Trash2, Archive, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
interface BulkActionsToolbarProps {
|
||||
selectedIds: string[];
|
||||
collection: string;
|
||||
onActionComplete?: () => void;
|
||||
}
|
||||
|
||||
export function BulkActionsToolbar({ selectedIds, collection, onActionComplete }: BulkActionsToolbarProps) {
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [lastAction, setLastAction] = useState('');
|
||||
|
||||
const handleBulkAction = async (action: string) => {
|
||||
if (selectedIds.length === 0) {
|
||||
alert('No items selected');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmMessage = `${action.toUpperCase()} ${selectedIds.length} item(s)?`;
|
||||
if (action === 'delete' && !confirm(`⚠️ ${confirmMessage}\nThis cannot be undone!`)) {
|
||||
return;
|
||||
} else if (!confirm(confirmMessage)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setLastAction(action);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/god/bulk-actions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action,
|
||||
collection,
|
||||
ids: selectedIds
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert(`✅ ${action.toUpperCase()} completed\n✓ Success: ${result.results.success}\n✗ Failed: ${result.results.failed}`);
|
||||
onActionComplete?.();
|
||||
} else {
|
||||
alert(`❌ Action failed: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Bulk action error:', error);
|
||||
alert('❌ Action failed. Check console for details.');
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
setLastAction('');
|
||||
}
|
||||
};
|
||||
|
||||
if (selectedIds.length === 0) {
|
||||
return (
|
||||
<div className="mb-4 p-3 bg-zinc-900 border border-zinc-800 rounded-lg text-zinc-500 text-sm flex items-center gap-2">
|
||||
<Square className="w-4 h-4" />
|
||||
<span>Select items to perform bulk actions</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-4 p-3 bg-zinc-900 border border-zinc-800 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-400">
|
||||
<CheckSquare className="w-4 h-4 text-green-400" />
|
||||
<span>{selectedIds.length} item(s) selected</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleBulkAction('publish')}
|
||||
disabled={isProcessing}
|
||||
className="px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-sm rounded flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
Publish
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleBulkAction('draft')}
|
||||
disabled={isProcessing}
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<EyeOff className="w-4 h-4" />
|
||||
Draft
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleBulkAction('archive')}
|
||||
disabled={isProcessing}
|
||||
className="px-3 py-1.5 bg-yellow-600 hover:bg-yellow-700 text-white text-sm rounded flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Archive className="w-4 h-4" />
|
||||
Archive
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleBulkAction('delete')}
|
||||
disabled={isProcessing}
|
||||
className="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white text-sm rounded flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isProcessing && (
|
||||
<div className="mt-2 text-xs text-zinc-400 flex items-center gap-2">
|
||||
<div className="w-3 h-3 border-2 border-zinc-600 border-t-white rounded-full animate-spin"></div>
|
||||
Processing {lastAction}...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
frontend/src/components/admin/god/ContentTable.tsx
Normal file
169
frontend/src/components/admin/god/ContentTable.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery } from '@tantml:parameter name="query';
|
||||
import { getDirectusClient, readItems } from '@/lib/directus/client';
|
||||
import { ExternalLink, CheckSquare, Square } from 'lucide-react';
|
||||
|
||||
interface ContentTableProps {
|
||||
collection: string;
|
||||
searchResults?: any[];
|
||||
onSelectionChange?: (ids: string[]) => void;
|
||||
}
|
||||
|
||||
export function ContentTable({ collection, searchResults, onSelectionChange }: ContentTableProps) {
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
|
||||
// Fetch data if no search results provided
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: [collection],
|
||||
queryFn: async () => {
|
||||
const directus = getDirectusClient();
|
||||
return await directus.request(readItems(collection, {
|
||||
limit: 100,
|
||||
sort: ['-date_created'],
|
||||
fields: ['*']
|
||||
}));
|
||||
},
|
||||
enabled: !searchResults
|
||||
});
|
||||
|
||||
const items = searchResults?.filter(r => r._collection === collection) || (data as any[]) || [];
|
||||
|
||||
useEffect(() => {
|
||||
onSelectionChange?.(selectedIds);
|
||||
}, [selectedIds, onSelectionChange]);
|
||||
|
||||
const toggleSelection = (id: string) => {
|
||||
setSelectedIds(prev =>
|
||||
prev.includes(id)
|
||||
? prev.filter(i => i !== id)
|
||||
: [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selectedIds.length === items.length) {
|
||||
setSelectedIds([]);
|
||||
} else {
|
||||
setSelectedIds(items.map((i: any) => i.id));
|
||||
}
|
||||
};
|
||||
|
||||
const getPreviewUrl = (item: any) => {
|
||||
if (collection === 'sites') return item.url;
|
||||
if (collection === 'pages') return `/preview/page/${item.id}`;
|
||||
if (collection === 'posts') return `/preview/post/${item.id}`;
|
||||
if (collection === 'generated_articles') return `/preview/article/${item.id}`;
|
||||
return null;
|
||||
};
|
||||
|
||||
const getTitle = (item: any) => {
|
||||
return item.title || item.name || item.headline || item.slug || item.id;
|
||||
};
|
||||
|
||||
const getStatus = (item: any) => {
|
||||
return item.status || item.is_published ? 'published' : 'draft';
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="bg-zinc-900 border border-zinc-800 rounded-lg p-4 animate-pulse">
|
||||
<div className="h-4 bg-zinc-800 rounded w-3/4"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-lg p-8 text-center text-zinc-500">
|
||||
No {collection} found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Header with select all */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-zinc-900 border border-zinc-800 rounded-lg text-sm text-zinc-400">
|
||||
<button
|
||||
onClick={toggleAll}
|
||||
className="hover:text-white transition-colors"
|
||||
>
|
||||
{selectedIds.length === items.length ? (
|
||||
<CheckSquare className="w-5 h-5 text-green-400" />
|
||||
) : (
|
||||
<Square className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
<span className="flex-1">
|
||||
{items.length} item(s) | {selectedIds.length} selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Items */}
|
||||
{items.map((item: any) => {
|
||||
const isSelected = selectedIds.includes(item.id);
|
||||
const previewUrl = getPreviewUrl(item);
|
||||
const title = getTitle(item);
|
||||
const status = getStatus(item);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`flex items-center gap-3 p-4 bg-zinc-900 border rounded-lg transition-all ${isSelected
|
||||
? 'border-green-500 bg-green-500/10'
|
||||
: 'border-zinc-800 hover:border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleSelection(item.id)}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
{isSelected ? (
|
||||
<CheckSquare className="w-5 h-5 text-green-400" />
|
||||
) : (
|
||||
<Square className="w-5 h-5 text-zinc-600 hover:text-zinc-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-white truncate">
|
||||
{title}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-zinc-500">
|
||||
<span className={`px-2 py-0.5 rounded ${status === 'published'
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: status === 'draft'
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'bg-zinc-700 text-zinc-400'
|
||||
}`}>
|
||||
{status}
|
||||
</span>
|
||||
{item.word_count && (
|
||||
<span>{item.word_count} words</span>
|
||||
)}
|
||||
{item.location_city && (
|
||||
<span>📍 {item.location_city}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{previewUrl && (
|
||||
<a
|
||||
href={previewUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-shrink-0 p-2 text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-5 h-5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
frontend/src/components/admin/god/GodModeCommandCenter.tsx
Normal file
107
frontend/src/components/admin/god/GodModeCommandCenter.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useState } from 'react';
|
||||
import { StatsPanel } from './StatsPanel';
|
||||
import { UnifiedSearchBar } from './UnifiedSearchBar';
|
||||
import { BulkActionsToolbar } from './BulkActionsToolbar';
|
||||
import { ContentTable } from './ContentTable';
|
||||
import { Database, FileText, File, Sparkles } from 'lucide-react';
|
||||
|
||||
type TabValue = 'sites' | 'pages' | 'posts' | 'articles';
|
||||
|
||||
const TABS = [
|
||||
{ value: 'sites' as const, label: 'Sites', icon: Database, collection: 'sites' },
|
||||
{ value: 'pages' as const, label: 'Pages', icon: FileText, collection: 'pages' },
|
||||
{ value: 'posts' as const, label: 'Posts', icon: File, collection: 'posts' },
|
||||
{ value: 'articles' as const, label: 'Articles', icon: Sparkles, collection: 'generated_articles' }
|
||||
];
|
||||
|
||||
export function GodModeCommandCenter() {
|
||||
const [activeTab, setActiveTab] = useState<TabValue>('sites');
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [searchResults, setSearchResults] = useState<any[] | undefined>(undefined);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const activeCollection = TABS.find(t => t.value === activeTab)?.collection || 'sites';
|
||||
|
||||
const handleActionComplete = () => {
|
||||
// Trigger refresh
|
||||
setRefreshKey(prev => prev + 1);
|
||||
setSelectedIds([]);
|
||||
setSearchResults(undefined);
|
||||
};
|
||||
|
||||
const handleSearch = (results: any[]) => {
|
||||
setSearchResults(results.length > 0 ? results : undefined);
|
||||
setSelectedIds([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">
|
||||
🔱 God Mode Command Center
|
||||
</h1>
|
||||
<p className="text-zinc-400">
|
||||
Unified control center for managing all sites, pages, posts, and generated articles
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Panel */}
|
||||
<StatsPanel />
|
||||
|
||||
{/* Search Bar */}
|
||||
<UnifiedSearchBar onSearch={handleSearch} />
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 mb-6 border-b border-zinc-800">
|
||||
{TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const isActive = activeTab === tab.value;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
onClick={() => {
|
||||
setActiveTab(tab.value);
|
||||
setSelectedIds([]);
|
||||
}}
|
||||
className={`flex items-center gap-2 px-4 py-3 border-b-2 transition-all ${isActive
|
||||
? 'border-white text-white'
|
||||
: 'border-transparent text-zinc-500 hover:text-zinc-300'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span className="font-medium">{tab.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions Toolbar */}
|
||||
<BulkActionsToolbar
|
||||
selectedIds={selectedIds}
|
||||
collection={activeCollection}
|
||||
onActionComplete={handleActionComplete}
|
||||
/>
|
||||
|
||||
{/* Content Table */}
|
||||
<ContentTable
|
||||
key={`${activeCollection}-${refreshKey}`}
|
||||
collection={activeCollection}
|
||||
searchResults={searchResults}
|
||||
onSelectionChange={setSelectedIds}
|
||||
/>
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="mt-6 p-4 bg-zinc-900 border border-zinc-800 rounded-lg text-sm text-zinc-500">
|
||||
<div className="font-medium text-zinc-400 mb-2">God Mode Features:</div>
|
||||
<ul className="space-y-1 ml-4">
|
||||
<li>• Search across all collections (sites, pages, posts, articles)</li>
|
||||
<li>• Bulk publish, draft, archive, or delete items</li>
|
||||
<li>• Direct database access via God Mode API</li>
|
||||
<li>• Real-time stats and live preview links</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
432
frontend/src/components/admin/god/SchemaTestDashboard.tsx
Normal file
432
frontend/src/components/admin/god/SchemaTestDashboard.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
/**
|
||||
* Schema Test Dashboard Component
|
||||
*
|
||||
* UI for running the God Mode build test and displaying results
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface TestResults {
|
||||
success: boolean;
|
||||
steps: {
|
||||
schemaValidation: boolean;
|
||||
dataSeeding: boolean;
|
||||
articleGeneration: boolean;
|
||||
outputValidation: boolean;
|
||||
};
|
||||
metrics: {
|
||||
siteId?: string;
|
||||
campaignId?: string;
|
||||
templateId?: string;
|
||||
articleId?: string;
|
||||
wordCount?: number;
|
||||
fragmentsCreated?: number;
|
||||
previewUrl?: string;
|
||||
};
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export function SchemaTestDashboard() {
|
||||
const [running, setRunning] = useState(false);
|
||||
const [results, setResults] = useState<TestResults | null>(null);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
|
||||
const runTest = async () => {
|
||||
setRunning(true);
|
||||
setLogs([]);
|
||||
setResults(null);
|
||||
|
||||
try {
|
||||
setLogs(prev => [...prev, '🔷 Starting God Mode Build Test...']);
|
||||
|
||||
const response = await fetch('/api/god/run-build-test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Test API failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setResults(data);
|
||||
|
||||
if (data.success) {
|
||||
setLogs(prev => [...prev, '✅ BUILD TEST PASSED']);
|
||||
} else {
|
||||
setLogs(prev => [...prev, '❌ BUILD TEST FAILED']);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setLogs(prev => [...prev, `❌ Error: ${error.message}`]);
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="schema-test-dashboard">
|
||||
<div className="test-header">
|
||||
<h2>🔷 God Mode Schema Test</h2>
|
||||
<p>Validates database schema and tests complete 2000+ word article generation workflow</p>
|
||||
</div>
|
||||
|
||||
<div className="test-controls">
|
||||
<button
|
||||
onClick={runTest}
|
||||
disabled={running}
|
||||
className={`btn btn-primary ${running ? 'loading' : ''}`}
|
||||
>
|
||||
{running ? '⏳ Running Test...' : '🚀 Run Build Test'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{logs.length > 0 && (
|
||||
<div className="test-logs">
|
||||
<h3>📋 Test Logs</h3>
|
||||
<div className="log-output">
|
||||
{logs.map((log, i) => (
|
||||
<div key={i} className="log-line">{log}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results && (
|
||||
<div className={`test-results ${results.success ? 'success' : 'failed'}`}>
|
||||
<h3>{results.success ? '✅ Test Passed' : '❌ Test Failed'}</h3>
|
||||
|
||||
{/* Steps Progress */}
|
||||
<div className="steps-grid">
|
||||
<div className={`step ${results.steps.schemaValidation ? 'pass' : 'fail'}`}>
|
||||
<span className="step-icon">{results.steps.schemaValidation ? '✅' : '❌'}</span>
|
||||
<span className="step-label">Schema Validation</span>
|
||||
</div>
|
||||
<div className={`step ${results.steps.dataSeeding ? 'pass' : 'fail'}`}>
|
||||
<span className="step-icon">{results.steps.dataSeeding ? '✅' : '❌'}</span>
|
||||
<span className="step-label">Data Seeding</span>
|
||||
</div>
|
||||
<div className={`step ${results.steps.articleGeneration ? 'pass' : 'fail'}`}>
|
||||
<span className="step-icon">{results.steps.articleGeneration ? '✅' : '❌'}</span>
|
||||
<span className="step-label">Article Generation</span>
|
||||
</div>
|
||||
<div className={`step ${results.steps.outputValidation ? 'pass' : 'fail'}`}>
|
||||
<span className="step-icon">{results.steps.outputValidation ? '✅' : '❌'}</span>
|
||||
<span className="step-label">Output Validation</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
{results.metrics && Object.keys(results.metrics).length > 0 && (
|
||||
<div className="metrics-panel">
|
||||
<h4>📊 Test Metrics</h4>
|
||||
<div className="metrics-grid">
|
||||
{results.metrics.siteId && (
|
||||
<div className="metric">
|
||||
<span className="label">Site ID:</span>
|
||||
<code>{results.metrics.siteId}</code>
|
||||
</div>
|
||||
)}
|
||||
{results.metrics.campaignId && (
|
||||
<div className="metric">
|
||||
<span className="label">Campaign ID:</span>
|
||||
<code>{results.metrics.campaignId}</code>
|
||||
</div>
|
||||
)}
|
||||
{results.metrics.articleId && (
|
||||
<div className="metric">
|
||||
<span className="label">Article ID:</span>
|
||||
<code>{results.metrics.articleId}</code>
|
||||
</div>
|
||||
)}
|
||||
{results.metrics.wordCount && (
|
||||
<div className="metric">
|
||||
<span className="label">Word Count:</span>
|
||||
<span className={results.metrics.wordCount >= 2000 ? 'success' : 'warning'}>
|
||||
{results.metrics.wordCount} words
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{results.metrics.fragmentsCreated && (
|
||||
<div className="metric">
|
||||
<span className="label">Fragments Created:</span>
|
||||
<span>{results.metrics.fragmentsCreated}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{results.metrics.previewUrl && (
|
||||
<div className="preview-link">
|
||||
<a
|
||||
href={results.metrics.previewUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
🔗 View Preview
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Errors */}
|
||||
{results.errors.length > 0 && (
|
||||
<div className="errors-panel">
|
||||
<h4>❌ Errors</h4>
|
||||
<ul>
|
||||
{results.errors.map((error, i) => (
|
||||
<li key={i} className="error">{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{results.warnings.length > 0 && (
|
||||
<div className="warnings-panel">
|
||||
<h4>⚠️ Warnings</h4>
|
||||
<ul>
|
||||
{results.warnings.map((warning, i) => (
|
||||
<li key={i} className="warning">{warning}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.schema-test-dashboard {
|
||||
padding: 2rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.test-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.test-header h2 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.test-header p {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.test-controls {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary.loading::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 8px;
|
||||
border: 2px solid white;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.test-logs {
|
||||
margin-bottom: 2rem;
|
||||
background: #1e1e1e;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.test-logs h3 {
|
||||
color: white;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.log-output {
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #0f0;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.log-line {
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.test-results {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.test-results.success {
|
||||
border-left: 4px solid #10b981;
|
||||
}
|
||||
|
||||
.test-results.failed {
|
||||
border-left: 4px solid #ef4444;
|
||||
}
|
||||
|
||||
.steps-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.step {
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
background: #f9fafb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.step.pass {
|
||||
background: #d1fae5;
|
||||
border: 1px solid #10b981;
|
||||
}
|
||||
|
||||
.step.fail {
|
||||
background: #fee2e2;
|
||||
border: 1px solid #ef4444;
|
||||
}
|
||||
|
||||
.step-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metrics-panel,
|
||||
.errors-panel,
|
||||
.warnings-panel {
|
||||
margin-top: 2rem;
|
||||
padding: 1.5rem;
|
||||
border-radius: 6px;
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.metrics-panel h4,
|
||||
.errors-panel h4,
|
||||
.warnings-panel h4 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.metric .label {
|
||||
font-size: 0.9rem;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.metric code {
|
||||
background: white;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.metric .success {
|
||||
color: #10b981;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.metric .warning {
|
||||
color: #f59e0b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.preview-link {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: #667eea;
|
||||
border: 2px solid #667eea;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.errors-panel {
|
||||
background: #fee2e2;
|
||||
border: 1px solid #ef4444;
|
||||
}
|
||||
|
||||
.warnings-panel {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
li.error {
|
||||
color: #dc2626;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
li.warning {
|
||||
color: #d97706;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
frontend/src/components/admin/god/StatsPanel.tsx
Normal file
78
frontend/src/components/admin/god/StatsPanel.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
interface StatsData {
|
||||
sites: number;
|
||||
pages: number;
|
||||
posts: number;
|
||||
articles: number;
|
||||
}
|
||||
|
||||
export function StatsPanel() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['god-mode-stats'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/api/god/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
collections: ['sites', 'pages', 'posts', 'generated_articles'],
|
||||
limit: 1
|
||||
})
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
// Count by collection
|
||||
const stats: StatsData = {
|
||||
sites: 0,
|
||||
pages: 0,
|
||||
posts: 0,
|
||||
articles: 0
|
||||
};
|
||||
|
||||
if (result.results) {
|
||||
result.results.forEach((item: any) => {
|
||||
if (item._collection === 'sites') stats.sites++;
|
||||
else if (item._collection === 'pages') stats.pages++;
|
||||
else if (item._collection === 'posts') stats.posts++;
|
||||
else if (item._collection === 'generated_articles') stats.articles++;
|
||||
});
|
||||
}
|
||||
|
||||
return stats;
|
||||
},
|
||||
refetchInterval: 30000 // Refresh every 30 seconds
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="bg-zinc-900 border border-zinc-800 rounded-lg p-4 animate-pulse">
|
||||
<div className="h-4 bg-zinc-800 rounded w-20 mb-2"></div>
|
||||
<div className="h-8 bg-zinc-800 rounded w-12"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = [
|
||||
{ label: 'Sites', value: data?.sites || 0, color: 'text-blue-400' },
|
||||
{ label: 'Pages', value: data?.pages || 0, color: 'text-green-400' },
|
||||
{ label: 'Posts', value: data?.posts || 0, color: 'text-purple-400' },
|
||||
{ label: 'Articles', value: data?.articles || 0, color: 'text-yellow-400' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="bg-zinc-900 border border-zinc-800 rounded-lg p-4">
|
||||
<div className="text-sm text-zinc-400 mb-1">{stat.label}</div>
|
||||
<div className={`text-3xl font-bold ${stat.color}`}>
|
||||
{stat.value.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
frontend/src/components/admin/god/UnifiedSearchBar.tsx
Normal file
75
frontend/src/components/admin/god/UnifiedSearchBar.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useState } from 'react';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
interface UnifiedSearchBarProps {
|
||||
onSearch: (results: any[]) => void;
|
||||
onLoading?: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export function UnifiedSearchBar({ onSearch, onLoading }: UnifiedSearchBarProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!query.trim()) {
|
||||
// Empty search = show all
|
||||
onSearch([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSearching(true);
|
||||
onLoading?.(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/god/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query: query.trim(),
|
||||
collections: ['sites', 'pages', 'posts', 'generated_articles'],
|
||||
limit: 100
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
onSearch(result.results || []);
|
||||
} else {
|
||||
console.error('Search failed:', result.error);
|
||||
onSearch([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
onSearch([]);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
onLoading?.(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSearch} className="mb-6">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search across all collections... (title, name, content, slug)"
|
||||
className="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3 pl-12 text-white placeholder-zinc-500 focus:outline-none focus:border-zinc-600"
|
||||
/>
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-500" />
|
||||
{isSearching && (
|
||||
<div className="absolute right-4 top-1/2 -translate-y-1/2">
|
||||
<div className="w-5 h-5 border-2 border-zinc-600 border-t-white rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-zinc-500">
|
||||
Searches: Sites, Pages, Posts, Generated Articles
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
8
frontend/src/pages/admin/god-mode.astro
Normal file
8
frontend/src/pages/admin/god-mode.astro
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
import AdminLayout from '@/layouts/AdminLayout.astro';
|
||||
import { GodModeCommandCenter } from '@/components/admin/god/GodModeCommandCenter';
|
||||
---
|
||||
|
||||
<AdminLayout title="God Mode Command Center">
|
||||
<GodModeCommandCenter client:load />
|
||||
</AdminLayout>
|
||||
138
frontend/src/pages/api/god/bulk-actions.ts
Normal file
138
frontend/src/pages/api/god/bulk-actions.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDirectusClient, updateItem, deleteItem } from '@/lib/directus/client';
|
||||
|
||||
/**
|
||||
* God Mode Bulk Actions
|
||||
*
|
||||
* Perform bulk operations on multiple items
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const { action, collection, ids, options } = await request.json();
|
||||
|
||||
if (!action || !collection || !ids || ids.length === 0) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'action, collection, and ids are required'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const directus = getDirectusClient();
|
||||
const results = {
|
||||
success: 0,
|
||||
failed: 0,
|
||||
errors: [] as any[]
|
||||
};
|
||||
|
||||
switch (action) {
|
||||
case 'publish':
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await directus.request(updateItem(collection, id, {
|
||||
status: 'published',
|
||||
published_at: new Date().toISOString()
|
||||
}));
|
||||
results.success++;
|
||||
} catch (error: any) {
|
||||
results.failed++;
|
||||
results.errors.push({ id, error: error.message });
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'unpublish':
|
||||
case 'draft':
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await directus.request(updateItem(collection, id, {
|
||||
status: 'draft'
|
||||
}));
|
||||
results.success++;
|
||||
} catch (error: any) {
|
||||
results.failed++;
|
||||
results.errors.push({ id, error: error.message });
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'archive':
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await directus.request(updateItem(collection, id, {
|
||||
status: 'archived'
|
||||
}));
|
||||
results.success++;
|
||||
} catch (error: any) {
|
||||
results.failed++;
|
||||
results.errors.push({ id, error: error.message });
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'delete':
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await directus.request(deleteItem(collection, id));
|
||||
results.success++;
|
||||
} catch (error: any) {
|
||||
results.failed++;
|
||||
results.errors.push({ id, error: error.message });
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'update':
|
||||
// Custom update with fields from options
|
||||
if (!options?.fields) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'options.fields required for update action'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await directus.request(updateItem(collection, id, options.fields));
|
||||
results.success++;
|
||||
} catch (error: any) {
|
||||
results.failed++;
|
||||
results.errors.push({ id, error: error.message });
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
return new Response(JSON.stringify({
|
||||
error: `Unknown action: ${action}`
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
action,
|
||||
collection,
|
||||
total: ids.length,
|
||||
results
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Bulk action error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: error.message
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
86
frontend/src/pages/api/god/search.ts
Normal file
86
frontend/src/pages/api/god/search.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDirectusClient, readItems } from '@/lib/directus/client';
|
||||
|
||||
/**
|
||||
* God Mode Unified Search
|
||||
*
|
||||
* Searches across multiple collections with filters
|
||||
*/
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const { query, collections, filters, limit = 100 } = await request.json();
|
||||
|
||||
if (!collections || collections.length === 0) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'collections array is required'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const directus = getDirectusClient();
|
||||
const results: any[] = [];
|
||||
|
||||
for (const collection of collections) {
|
||||
try {
|
||||
// Build filter
|
||||
const filter: any = {};
|
||||
|
||||
// Add text search if query provided
|
||||
if (query) {
|
||||
filter._or = [
|
||||
{ title: { _contains: query } },
|
||||
{ name: { _contains: query } },
|
||||
{ headline: { _contains: query } },
|
||||
{ content: { _contains: query } },
|
||||
{ slug: { _contains: query } }
|
||||
];
|
||||
}
|
||||
|
||||
// Merge additional filters
|
||||
if (filters) {
|
||||
Object.assign(filter, filters);
|
||||
}
|
||||
|
||||
const items = await directus.request(readItems(collection, {
|
||||
filter: Object.keys(filter).length > 0 ? filter : undefined,
|
||||
limit: Math.min(limit, 100),
|
||||
fields: ['*']
|
||||
}));
|
||||
|
||||
// Add collection name to each item
|
||||
const itemsWithCollection = (items as any[]).map(item => ({
|
||||
...item,
|
||||
_collection: collection
|
||||
}));
|
||||
|
||||
results.push(...itemsWithCollection);
|
||||
} catch (error) {
|
||||
console.error(`Error searching ${collection}:`, error);
|
||||
// Continue with other collections
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
results,
|
||||
total: results.length,
|
||||
query,
|
||||
collections
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Search error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
success: false,
|
||||
error: error.message
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user