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