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:
cawcenter
2025-12-15 12:21:30 -05:00
parent 7eb882a906
commit 0760450a6d
9 changed files with 1215 additions and 0 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>

View 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' }
});
}
};

View 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' }
});
}
};