WIP: Collection Manager infrastructure
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getDirectusClient, readItems } from '@/lib/directus/client';
|
||||
|
||||
interface SystemStatus {
|
||||
coreApi: 'online' | 'offline' | 'checking';
|
||||
@@ -24,27 +23,38 @@ export default function SystemStatusBar() {
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
checkSystemStatus();
|
||||
const interval = setInterval(checkSystemStatus, 30000); // Check every 30 seconds
|
||||
checkStatus();
|
||||
const interval = setInterval(checkStatus, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const checkSystemStatus = async () => {
|
||||
const addLog = (message: string, type: LogEntry['type']) => {
|
||||
const newLog: LogEntry = {
|
||||
time: new Date().toLocaleTimeString(),
|
||||
message,
|
||||
type
|
||||
};
|
||||
setLogs(prev => [newLog, ...prev].slice(0, 50));
|
||||
};
|
||||
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const client = getDirectusClient();
|
||||
const directusUrl = 'https://spark.jumpstartscaling.com';
|
||||
|
||||
// Test database connection by fetching a single site
|
||||
const sites = await client.request(
|
||||
readItems('sites', { limit: 1 })
|
||||
);
|
||||
|
||||
setStatus({
|
||||
coreApi: 'online',
|
||||
database: 'connected',
|
||||
wpConnection: 'ready'
|
||||
const response = await fetch(`${directusUrl}/server/health`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
addLog('System check passed', 'success');
|
||||
if (response.ok) {
|
||||
setStatus({
|
||||
coreApi: 'online',
|
||||
database: 'connected',
|
||||
wpConnection: 'ready'
|
||||
});
|
||||
addLog('System check passed', 'success');
|
||||
} else {
|
||||
throw new Error(`Health check failed: ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Status check failed:', error);
|
||||
setStatus({
|
||||
@@ -56,15 +66,6 @@ export default function SystemStatusBar() {
|
||||
}
|
||||
};
|
||||
|
||||
const addLog = (message: string, type: LogEntry['type']) => {
|
||||
const newLog: LogEntry = {
|
||||
time: new Date().toLocaleTimeString(),
|
||||
message,
|
||||
type
|
||||
};
|
||||
setLogs(prev => [newLog, ...prev].slice(0, 50)); // Keep last 50 logs
|
||||
};
|
||||
|
||||
const getStatusColor = (state: string) => {
|
||||
switch (state) {
|
||||
case 'online':
|
||||
@@ -96,60 +97,53 @@ export default function SystemStatusBar() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 bg-slate-800 border-t border-slate-700 shadow-lg">
|
||||
{/* Main Status Bar */}
|
||||
<div className="fixed bottom-0 left-0 right-0 z-50 bg-titanium border-t border-edge-normal shadow-xl">
|
||||
<div className="container mx-auto px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-semibold text-white whitespace-nowrap">
|
||||
API & Logistics
|
||||
</h3>
|
||||
<h3 className="spark-label text-white">API & Logistics</h3>
|
||||
|
||||
{/* Status Items */}
|
||||
<div className="flex items-center gap-6 flex-1">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-slate-400">Core API</span>
|
||||
<span className="text-silver">Core API</span>
|
||||
<span className={getStatusColor(status.coreApi)}>
|
||||
{status.coreApi.charAt(0).toUpperCase() + status.coreApi.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-slate-400">Database (Directus</span>
|
||||
<span className="text-silver">Database (Directus)</span>
|
||||
<span className={getStatusColor(status.database)}>
|
||||
{status.database.charAt(0).toUpperCase() + status.database.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-slate-400">WP Connection</span>
|
||||
<span className="text-silver">WP Connection</span>
|
||||
<span className={getStatusColor(status.wpConnection)}>
|
||||
{status.wpConnection.charAt(0).toUpperCase() + status.wpConnection.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle Logs Button */}
|
||||
<button
|
||||
onClick={() => setShowLogs(!showLogs)}
|
||||
className="px-3 py-1.5 text-sm bg-slate-700 hover:bg-slate-600 text-white rounded border border-slate-600 transition-colors whitespace-nowrap"
|
||||
className="spark-btn-ghost text-sm"
|
||||
>
|
||||
{showLogs ? 'Hide' : 'Show'} Processing Log
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Processing Log Panel */}
|
||||
{showLogs && (
|
||||
<div className="border-t border-slate-700 bg-slate-900">
|
||||
<div className="border-t border-edge-subtle bg-void">
|
||||
<div className="container mx-auto px-4 py-3 max-h-48 overflow-y-auto">
|
||||
<div className="space-y-1">
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-sm text-slate-500 italic">No recent activity</div>
|
||||
<div className="text-sm text-silver/50 italic">No recent activity</div>
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<div key={index} className="flex items-start gap-2 text-sm font-mono">
|
||||
<span className="text-slate-500 shrink-0">[{log.time}]</span>
|
||||
<span className="text-silver/50 shrink-0">[{log.time}]</span>
|
||||
<span className={getLogColor(log.type)}>{log.message}</span>
|
||||
</div>
|
||||
))
|
||||
|
||||
176
frontend/src/components/collections/CollectionManager.tsx
Normal file
176
frontend/src/components/collections/CollectionManager.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Universal Collection Manager
|
||||
* Reusable component for all collection pages
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
interface CollectionManagerProps {
|
||||
collection: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
displayField: string;
|
||||
}
|
||||
|
||||
export default function CollectionManager({
|
||||
collection,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
displayField,
|
||||
}: CollectionManagerProps) {
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [collection]);
|
||||
|
||||
const fetchItems = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(
|
||||
`https://spark.jumpstartscaling.com/items/${collection}?limit=100`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${import.meta.env.PUBLIC_DIRECTUS_TOKEN}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) throw new Error(`Failed to fetch ${collection}`);
|
||||
|
||||
const data = await response.json();
|
||||
setItems(data.data || []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkExport = () => {
|
||||
const jsonStr = JSON.stringify(items, null, 2);
|
||||
const blob = new Blob([jsonStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${collection}_export.json`;
|
||||
a.click();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="spark-data animate-pulse">Loading {title}...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="spark-card p-6 border-red-500">
|
||||
<div className="text-red-400">Error: {error}</div>
|
||||
<button onClick={fetchItems} className="spark-btn-secondary text-sm mt-4">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="spark-heading text-3xl flex items-center gap-3">
|
||||
<span>{icon}</span>
|
||||
{title}
|
||||
</h1>
|
||||
<p className="text-silver mt-1">{description}</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button className="spark-btn-secondary text-sm" onClick={handleBulkExport}>
|
||||
📤 Export
|
||||
</button>
|
||||
<button className="spark-btn-secondary text-sm">
|
||||
📥 Import
|
||||
</button>
|
||||
<button className="spark-btn-primary text-sm">
|
||||
✨ New {title.slice(0, -1)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="spark-card p-6">
|
||||
<div className="spark-label mb-2">Total Items</div>
|
||||
<div className="spark-data text-3xl">{items.length}</div>
|
||||
</div>
|
||||
<div className="spark-card p-6">
|
||||
<div className="spark-label mb-2">This Week</div>
|
||||
<div className="spark-data text-3xl">0</div>
|
||||
</div>
|
||||
<div className="spark-card p-6">
|
||||
<div className="spark-label mb-2">Usage Count</div>
|
||||
<div className="spark-data text-3xl">—</div>
|
||||
</div>
|
||||
<div className="spark-card p-6">
|
||||
<div className="spark-label mb-2">Status</div>
|
||||
<div className="text-green-400 text-sm">● Active</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="spark-card overflow-hidden">
|
||||
<table className="spark-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-12">
|
||||
<input type="checkbox" className="w-4 h-4" />
|
||||
</th>
|
||||
<th>ID</th>
|
||||
<th>{displayField}</th>
|
||||
<th>Created</th>
|
||||
<th className="text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td>
|
||||
<input type="checkbox" className="w-4 h-4" />
|
||||
</td>
|
||||
<td className="spark-data text-sm">{item.id}</td>
|
||||
<td className="text-white font-medium">
|
||||
{item[displayField] || 'Untitled'}
|
||||
</td>
|
||||
<td className="text-silver text-sm">
|
||||
{item.date_created
|
||||
? new Date(item.date_created).toLocaleDateString()
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="text-right">
|
||||
<button className="spark-btn-ghost text-xs px-2 py-1">
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{items.length === 0 && (
|
||||
<div className="p-12 text-center">
|
||||
<p className="text-silver/50">No items found. Create your first one!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
frontend/src/lib/collections/config.ts
Normal file
107
frontend/src/lib/collections/config.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Collection Page Template Generator
|
||||
* Creates standardized CRUD pages for all collections
|
||||
*/
|
||||
|
||||
export const collectionConfigs = {
|
||||
avatar_intelligence: {
|
||||
title: 'Avatar Intelligence',
|
||||
description: 'Manage persona profiles and variants',
|
||||
icon: '👥',
|
||||
fields: ['base_name', 'wealth_cluster', 'business_niches'],
|
||||
displayField: 'base_name',
|
||||
},
|
||||
avatar_variants: {
|
||||
title: 'Avatar Variants',
|
||||
description: 'Manage gender and tone variations',
|
||||
icon: '🎭',
|
||||
fields: ['avatar_id', 'variant_name', 'pronouns'],
|
||||
displayField: 'variant_name',
|
||||
},
|
||||
campaign_masters: {
|
||||
title: 'Campaign Masters',
|
||||
description: 'Manage marketing campaigns',
|
||||
icon: '📢',
|
||||
fields: ['campaign_name', 'status', 'site_id'],
|
||||
displayField: 'campaign_name',
|
||||
},
|
||||
cartesian_patterns: {
|
||||
title: 'Cartesian Patterns',
|
||||
description: 'Content structure templates',
|
||||
icon: '🔧',
|
||||
fields: ['pattern_name', 'structure_type'],
|
||||
displayField: 'pattern_name',
|
||||
},
|
||||
content_fragments: {
|
||||
title: 'Content Fragments',
|
||||
description: 'Reusable content blocks',
|
||||
icon: '📦',
|
||||
fields: ['fragment_type', 'content'],
|
||||
displayField: 'fragment_type',
|
||||
},
|
||||
generated_articles: {
|
||||
title: 'Generated Articles',
|
||||
description: 'AI-generated content output',
|
||||
icon: '📝',
|
||||
fields: ['title', 'status', 'seo_score', 'geo_city'],
|
||||
displayField: 'title',
|
||||
},
|
||||
generation_jobs: {
|
||||
title: 'Generation Jobs',
|
||||
description: 'Content generation queue',
|
||||
icon: '⚙️',
|
||||
fields: ['job_name', 'status', 'progress'],
|
||||
displayField: 'job_name',
|
||||
},
|
||||
geo_intelligence: {
|
||||
title: 'Geo Intelligence',
|
||||
description: 'Location targeting data',
|
||||
icon: '🗺️',
|
||||
fields: ['city', 'state', 'zip', 'population'],
|
||||
displayField: 'city',
|
||||
},
|
||||
headline_inventory: {
|
||||
title: 'Headline Inventory',
|
||||
description: 'Pre-written headlines library',
|
||||
icon: '💬',
|
||||
fields: ['headline_text', 'category'],
|
||||
displayField: 'headline_text',
|
||||
},
|
||||
leads: {
|
||||
title: 'Leads',
|
||||
description: 'Customer lead management',
|
||||
icon: '👤',
|
||||
fields: ['name', 'email', 'status'],
|
||||
displayField: 'name',
|
||||
},
|
||||
offer_blocks: {
|
||||
title: 'Offer Blocks',
|
||||
description: 'Call-to-action templates',
|
||||
icon: '🎯',
|
||||
fields: ['offer_text', 'offer_type'],
|
||||
displayField: 'offer_text',
|
||||
},
|
||||
pages: {
|
||||
title: 'Pages',
|
||||
description: 'Static page content',
|
||||
icon: '📄',
|
||||
fields: ['title', 'slug', 'status'],
|
||||
displayField: 'title',
|
||||
},
|
||||
posts: {
|
||||
title: 'Posts',
|
||||
description: 'Blog posts and articles',
|
||||
icon: '📰',
|
||||
fields: ['title', 'status', 'seo_score'],
|
||||
displayField: 'title',
|
||||
},
|
||||
spintax_dictionaries: {
|
||||
title: 'Spintax Dictionaries',
|
||||
description: 'Word variation sets',
|
||||
icon: '📚',
|
||||
fields: ['category', 'variations'],
|
||||
displayField: 'category',
|
||||
},
|
||||
};
|
||||
|
||||
export type CollectionName = keyof typeof collectionConfigs;
|
||||
107
frontend/src/pages/admin/intelligence/avatars.astro
Normal file
107
frontend/src/pages/admin/intelligence/avatars.astro
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Avatar Intelligence Management
|
||||
* Full CRUD for avatar_intelligence collection
|
||||
*/
|
||||
|
||||
---
|
||||
import AdminLayout from '@/layouts/AdminLayout.astro';
|
||||
import { getDirectusClient } from '@/lib/directus/client';
|
||||
import { readItems } from '@directus/sdk';
|
||||
|
||||
const client = getDirectusClient();
|
||||
|
||||
let avatars = [];
|
||||
let error = null;
|
||||
|
||||
try {
|
||||
avatars = await client.request(readItems('avatar_intelligence', {
|
||||
fields: ['*'],
|
||||
sort: ['base_name'],
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Error fetching avatars:', e);
|
||||
error = e instanceof Error ? e.message : 'Unknown error';
|
||||
}
|
||||
---
|
||||
|
||||
<AdminLayout title="Avatar Intelligence">
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="spark-heading text-3xl">Avatar Intelligence</h1>
|
||||
<p class="text-silver mt-1">Manage persona profiles and variants</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button class="spark-btn-secondary text-sm">
|
||||
📥 Import CSV
|
||||
</button>
|
||||
<button class="spark-btn-secondary text-sm">
|
||||
📤 Export
|
||||
</button>
|
||||
<a href="/admin/intelligence/avatars/new" class="spark-btn-primary text-sm">
|
||||
✨ New Avatar
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div class="spark-card p-4 border-red-500 text-red-400">
|
||||
Error: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Total Avatars</div>
|
||||
<div class="spark-data text-3xl">{avatars.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Avatars Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{avatars.map((avatar: any) => (
|
||||
<div class="spark-card spark-card-hover p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<h3 class="text-white font-semibold text-lg">{avatar.base_name}</h3>
|
||||
<button class="spark-btn-ghost text-xs px-2 py-1">
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
<div>
|
||||
<span class="spark-label">Wealth Cluster:</span>
|
||||
<span class="text-silver ml-2">{avatar.wealth_cluster || 'Not set'}</span>
|
||||
</div>
|
||||
|
||||
{avatar.business_niches && (
|
||||
<div>
|
||||
<span class="spark-label">Niches:</span>
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
{avatar.business_niches.slice(0, 3).map((niche: string) => (
|
||||
<span class="px-2 py-0.5 bg-graphite border border-edge-subtle rounded text-xs text-silver">
|
||||
{niche}
|
||||
</span>
|
||||
))}
|
||||
{avatar.business_niches.length > 3 && (
|
||||
<span class="px-2 py-0.5 text-xs text-silver/50">
|
||||
+{avatar.business_niches.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{avatars.length === 0 && !error && (
|
||||
<div class="col-span-full spark-card p-12 text-center">
|
||||
<p class="text-silver/50">No avatars found. Create your first one!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
Reference in New Issue
Block a user