WIP: Collection Manager infrastructure

This commit is contained in:
cawcenter
2025-12-13 12:42:14 -05:00
parent d4b7e61cdb
commit 3060a8531c
4 changed files with 424 additions and 40 deletions

View File

@@ -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>
))

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

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

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