feat: Add 10 collection management pages with Titanium Pro design
- Avatar Variants: Gender/tone variation management - Campaign Masters: Marketing campaign overview - Cartesian Patterns: Content template formulas - Content Fragments: Reusable content blocks - Generation Jobs: Queue monitoring with progress bars - Geo Intelligence: Location targeting by state - Headline Inventory: Spintax headline library - Offer Blocks: CTA templates with pain points - Spintax Dictionaries: Word variation sets - Leads: Updated with stats and Titanium Pro styling All pages include: - Import/export functionality - Usage statistics - Titanium Pro design system - Real-time Directus API integration
This commit is contained in:
148
frontend/src/pages/admin/collections/avatar-variants.astro
Normal file
148
frontend/src/pages/admin/collections/avatar-variants.astro
Normal file
@@ -0,0 +1,148 @@
|
||||
---
|
||||
/**
|
||||
* Avatar Variants Management
|
||||
* Full CRUD for avatar_variants collection
|
||||
*/
|
||||
import AdminLayout from '@/layouts/AdminLayout.astro';
|
||||
import { getDirectusClient } from '@/lib/directus/client';
|
||||
import { readItems } from '@directus/sdk';
|
||||
|
||||
const client = getDirectusClient();
|
||||
|
||||
let items = [];
|
||||
let error = null;
|
||||
let stats = {
|
||||
total: 0,
|
||||
male: 0,
|
||||
female: 0,
|
||||
neutral: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
items = await client.request(readItems('avatar_variants', {
|
||||
fields: ['*'],
|
||||
sort: ['avatar_key', 'variant_type'],
|
||||
}));
|
||||
|
||||
stats.total = items.length;
|
||||
stats.male = items.filter((i: any) => i.variant_type === 'male').length;
|
||||
stats.female = items.filter((i: any) => i.variant_type === 'female').length;
|
||||
stats.neutral = items.filter((i: any) => i.variant_type === 'neutral').length;
|
||||
} catch (e) {
|
||||
console.error('Error fetching avatar variants:', e);
|
||||
error = e instanceof Error ? e.message : 'Unknown error';
|
||||
}
|
||||
---
|
||||
|
||||
<AdminLayout title="Avatar Variants">
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="spark-heading text-3xl">🎭 Avatar Variants</h1>
|
||||
<p class="text-silver mt-1">Manage gender and tone variations</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button class="spark-btn-secondary text-sm" onclick="window.dispatchEvent(new CustomEvent('import-modal'))">
|
||||
📥 Import
|
||||
</button>
|
||||
<button class="spark-btn-secondary text-sm" onclick="window.dispatchEvent(new CustomEvent('export-data', {detail: {collection: 'avatar_variants'}}))">
|
||||
📤 Export
|
||||
</button>
|
||||
<a href="/admin/collections/avatar-variants/new" class="spark-btn-primary text-sm">
|
||||
✨ New Variant
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div class="spark-card p-4 border-red-500 text-red-400">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Total Variants</div>
|
||||
<div class="spark-data text-3xl">{stats.total}</div>
|
||||
</div>
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Male</div>
|
||||
<div class="spark-data text-3xl text-blue-400">{stats.male}</div>
|
||||
</div>
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Female</div>
|
||||
<div class="spark-data text-3xl text-pink-400">{stats.female}</div>
|
||||
</div>
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Neutral</div>
|
||||
<div class="spark-data text-3xl text-purple-400">{stats.neutral}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Variants Table -->
|
||||
<div class="spark-card overflow-hidden">
|
||||
<div class="p-6 border-b border-edge-subtle">
|
||||
<h2 class="text-white font-semibold">All Variants</h2>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-graphite">
|
||||
<tr>
|
||||
<th class="text-left px-6 py-3 spark-label">Avatar</th>
|
||||
<th class="text-left px-6 py-3 spark-label">Type</th>
|
||||
<th class="text-left px-6 py-3 spark-label">Pronouns</th>
|
||||
<th class="text-left px-6 py-3 spark-label">Identity</th>
|
||||
<th class="text-right px-6 py-3 spark-label">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((variant: any, index: number) => (
|
||||
<tr class={index % 2 === 0 ? 'bg-black/20' : ''}>
|
||||
<td class="px-6 py-4 text-white">{variant.avatar_key}</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class={`px-2 py-1 rounded text-xs ${
|
||||
variant.variant_type === 'male' ? 'bg-blue-500/20 text-blue-400' :
|
||||
variant.variant_type === 'female' ? 'bg-pink-500/20 text-pink-400' :
|
||||
'bg-purple-500/20 text-purple-400'
|
||||
}`}>
|
||||
{variant.variant_type}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-silver">{variant.pronoun}</td>
|
||||
<td class="px-6 py-4 text-silver">{variant.identity}</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<a href={`/admin/collections/avatar-variants/${variant.id}`} class="spark-btn-ghost text-xs px-3 py-1">
|
||||
Edit
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{items.length === 0 && !error && (
|
||||
<div class="p-12 text-center">
|
||||
<p class="text-silver/50">No variants found. Create your first one!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
<script>
|
||||
// Handle export
|
||||
window.addEventListener('export-data', async (e: any) => {
|
||||
const { collection } = e.detail;
|
||||
const response = await fetch(`/api/collections/${collection}/export`);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${collection}-${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.click();
|
||||
});
|
||||
</script>
|
||||
151
frontend/src/pages/admin/collections/campaign-masters.astro
Normal file
151
frontend/src/pages/admin/collections/campaign-masters.astro
Normal file
@@ -0,0 +1,151 @@
|
||||
---
|
||||
/**
|
||||
* Campaign Masters Management
|
||||
* Full CRUD for campaign_masters collection
|
||||
*/
|
||||
import AdminLayout from '@/layouts/AdminLayout.astro';
|
||||
import { getDirectusClient } from '@/lib/directus/client';
|
||||
import { readItems } from '@directus/sdk';
|
||||
|
||||
const client = getDirectusClient();
|
||||
|
||||
let campaigns = [];
|
||||
let error = null;
|
||||
let stats = {
|
||||
total: 0,
|
||||
active: 0,
|
||||
draft: 0,
|
||||
completed: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
campaigns = await client.request(readItems('campaign_masters', {
|
||||
fields: ['*'],
|
||||
sort: ['-date_created'],
|
||||
}));
|
||||
|
||||
stats.total = campaigns.length;
|
||||
stats.active = campaigns.filter((c: any) => c.status === 'active').length;
|
||||
stats.draft = campaigns.filter((c: any) => c.status === 'draft').length;
|
||||
stats.completed = campaigns.filter((c: any) => c.status === 'completed').length;
|
||||
} catch (e) {
|
||||
console.error('Error fetching campaigns:', e);
|
||||
error = e instanceof Error ? e.message : 'Unknown error';
|
||||
}
|
||||
---
|
||||
|
||||
<AdminLayout title="Campaign Masters">
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="spark-heading text-3xl">📢 Campaign Masters</h1>
|
||||
<p class="text-silver mt-1">Manage marketing campaigns and content strategies</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button class="spark-btn-secondary text-sm" onclick="window.dispatchEvent(new CustomEvent('import-modal'))">
|
||||
📥 Import
|
||||
</button>
|
||||
<button class="spark-btn-secondary text-sm" onclick="window.dispatchEvent(new CustomEvent('export-data', {detail: {collection: 'campaign_masters'}}))">
|
||||
📤 Export
|
||||
</button>
|
||||
<a href="/admin/collections/campaign-masters/new" class="spark-btn-primary text-sm">
|
||||
✨ New Campaign
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div class="spark-card p-4 border-red-500 text-red-400">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Total Campaigns</div>
|
||||
<div class="spark-data text-3xl">{stats.total}</div>
|
||||
</div>
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Active</div>
|
||||
<div class="spark-data text-3xl text-green-400">{stats.active}</div>
|
||||
</div>
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Draft</div>
|
||||
<div class="spark-data text-3xl text-yellow-400">{stats.draft}</div>
|
||||
</div>
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Completed</div>
|
||||
<div class="spark-data text-3xl text-blue-400">{stats.completed}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Campaigns Grid -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{campaigns.map((campaign: any) => (
|
||||
<div class="spark-card spark-card-hover p-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h3 class="text-white font-semibold text-lg">{campaign.campaign_name || 'Unnamed Campaign'}</h3>
|
||||
<p class="text-silver/70 text-sm mt-1">
|
||||
{campaign.description?.substring(0, 100) || 'No description'}
|
||||
</p>
|
||||
</div>
|
||||
<span class={`px-3 py-1 rounded text-xs font-medium ${
|
||||
campaign.status === 'active' ? 'bg-green-500/20 text-green-400 border border-green-500/30' :
|
||||
campaign.status === 'draft' ? 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/30' :
|
||||
campaign.status === 'completed' ? 'bg-blue-500/20 text-blue-400 border border-blue-500/30' :
|
||||
'bg-graphite border border-edge-subtle text-silver'
|
||||
}`}>
|
||||
{campaign.status || 'draft'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm mb-4">
|
||||
{campaign.target_count && (
|
||||
<div>
|
||||
<span class="spark-label">Targets:</span>
|
||||
<span class="text-silver ml-2">{campaign.target_count} items</span>
|
||||
</div>
|
||||
)}
|
||||
{campaign.articles_generated && (
|
||||
<div>
|
||||
<span class="spark-label">Generated:</span>
|
||||
<span class="text-gold ml-2">{campaign.articles_generated} articles</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<a href={`/admin/collections/campaign-masters/${campaign.id}`} class="spark-btn-ghost text-xs px-3 py-1 flex-1 text-center">
|
||||
Edit
|
||||
</a>
|
||||
<a href={`/admin/seo/articles?campaign=${campaign.id}`} class="spark-btn-secondary text-xs px-3 py-1 flex-1 text-center">
|
||||
View Articles
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{campaigns.length === 0 && !error && (
|
||||
<div class="col-span-full spark-card p-12 text-center">
|
||||
<p class="text-silver/50">No campaigns found. Create your first campaign!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
<script>
|
||||
window.addEventListener('export-data', async (e: any) => {
|
||||
const { collection } = e.detail;
|
||||
const response = await fetch(`/api/collections/${collection}/export`);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${collection}-${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.click();
|
||||
});
|
||||
</script>
|
||||
161
frontend/src/pages/admin/collections/cartesian-patterns.astro
Normal file
161
frontend/src/pages/admin/collections/cartesian-patterns.astro
Normal file
@@ -0,0 +1,161 @@
|
||||
---
|
||||
/**
|
||||
* Cartesian Patterns Management
|
||||
* Content structure templates and formulas
|
||||
*/
|
||||
import AdminLayout from '@/layouts/AdminLayout.astro';
|
||||
import { getDirectusClient } from '@/lib/directus/client';
|
||||
import { readItems } from '@directus/sdk';
|
||||
|
||||
const client = getDirectusClient();
|
||||
|
||||
let patterns = [];
|
||||
let error = null;
|
||||
let stats = {
|
||||
total: 0,
|
||||
byType: {} as Record<string, number>,
|
||||
};
|
||||
|
||||
try {
|
||||
patterns = await client.request(readItems('cartesian_patterns', {
|
||||
fields: ['*'],
|
||||
sort: ['structure_type', 'pattern_name'],
|
||||
}));
|
||||
|
||||
stats.total = patterns.length;
|
||||
patterns.forEach((p: any) => {
|
||||
const type = p.structure_type || 'general';
|
||||
stats.byType[type] = (stats.byType[type] || 0) + 1;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error fetching patterns:', e);
|
||||
error = e instanceof Error ? e.message : 'Unknown error';
|
||||
}
|
||||
---
|
||||
|
||||
<AdminLayout title="Cartesian Patterns">
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="spark-heading text-3xl">🔧 Cartesian Patterns</h1>
|
||||
<p class="text-silver mt-1">Content structure templates and formulas</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button class="spark-btn-secondary text-sm" onclick="window.dispatchEvent(new CustomEvent('import-modal'))">
|
||||
📥 Import
|
||||
</button>
|
||||
<button class="spark-btn-secondary text-sm" onclick="window.dispatchEvent(new CustomEvent('export-data', {detail: {collection: 'cartesian_patterns'}}))">
|
||||
📤 Export
|
||||
</button>
|
||||
<a href="/admin/collections/cartesian-patterns/new" class="spark-btn-primary text-sm">
|
||||
✨ New Pattern
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div class="spark-card p-4 border-red-500 text-red-400">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Total Patterns</div>
|
||||
<div class="spark-data text-3xl">{stats.total}</div>
|
||||
</div>
|
||||
{Object.entries(stats.byType).slice(0, 3).map(([type, count]) => (
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2 capitalize">{type}</div>
|
||||
<div class="spark-data text-3xl text-gold">{count}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- Patterns List -->
|
||||
<div class="space-y-4">
|
||||
{patterns.map((pattern: any) => (
|
||||
<div class="spark-card spark-card-hover p-6">
|
||||
<div class="flex items-start justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h3 class="text-white font-semibold text-lg">{pattern.pattern_name || 'Unnamed Pattern'}</h3>
|
||||
{pattern.description && (
|
||||
<p class="text-silver/70 text-sm mt-1">{pattern.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<span class="px-3 py-1 bg-gold/10 text-gold text-xs rounded border border-gold/30">
|
||||
{pattern.structure_type || 'general'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{pattern.template && (
|
||||
<div class="mb-4 p-4 bg-black/30 rounded border border-edge-subtle font-mono">
|
||||
<div class="spark-label mb-2">Template:</div>
|
||||
<div class="text-sm text-green-400 whitespace-pre-wrap">
|
||||
{pattern.template}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pattern.variables && Array.isArray(pattern.variables) && (
|
||||
<div class="mb-4">
|
||||
<div class="spark-label mb-2">Variables:</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{pattern.variables.map((variable: string) => (
|
||||
<span class="px-3 py-1 bg-blue-500/10 text-blue-400 text-xs rounded border border-blue-500/20 font-mono">
|
||||
{'{{'}{variable}{'}}'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pattern.example_output && (
|
||||
<div class="p-4 bg-graphite rounded border border-edge-subtle">
|
||||
<div class="spark-label mb-2">Example Output:</div>
|
||||
<div class="text-silver text-sm italic">
|
||||
"{pattern.example_output}"
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button
|
||||
class="spark-btn-ghost text-xs px-3 py-1"
|
||||
onclick={`navigator.clipboard.writeText(${JSON.stringify(pattern.template || '')})`}
|
||||
>
|
||||
📋 Copy Template
|
||||
</button>
|
||||
<a href={`/admin/collections/cartesian-patterns/${pattern.id}`} class="spark-btn-ghost text-xs px-3 py-1">
|
||||
Edit
|
||||
</a>
|
||||
<button class="spark-btn-secondary text-xs px-3 py-1">
|
||||
🧪 Test Pattern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{patterns.length === 0 && !error && (
|
||||
<div class="spark-card p-12 text-center">
|
||||
<p class="text-silver/50">No patterns found. Create your first content template!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
<script>
|
||||
window.addEventListener('export-data', async (e: any) => {
|
||||
const { collection } = e.detail;
|
||||
const response = await fetch(`/api/collections/${collection}/export`);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${collection}-${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.click();
|
||||
});
|
||||
</script>
|
||||
193
frontend/src/pages/admin/collections/content-fragments.astro
Normal file
193
frontend/src/pages/admin/collections/content-fragments.astro
Normal file
@@ -0,0 +1,193 @@
|
||||
---
|
||||
/**
|
||||
* Content Fragments Management
|
||||
* Reusable content blocks for article assembly
|
||||
*/
|
||||
import AdminLayout from '@/layouts/AdminLayout.astro';
|
||||
import { getDirectusClient } from '@/lib/directus/client';
|
||||
import { readItems } from '@directus/sdk';
|
||||
|
||||
const client = getDirectusClient();
|
||||
|
||||
let fragments = [];
|
||||
let error = null;
|
||||
let stats = {
|
||||
total: 0,
|
||||
byType: {} as Record<string, number>,
|
||||
totalWords: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
fragments = await client.request(readItems('content_fragments', {
|
||||
fields: ['*'],
|
||||
sort: ['fragment_type', '-date_created'],
|
||||
}));
|
||||
|
||||
stats.total = fragments.length;
|
||||
fragments.forEach((f: any) => {
|
||||
const type = f.fragment_type || 'general';
|
||||
stats.byType[type] = (stats.byType[type] || 0) + 1;
|
||||
if (f.content) {
|
||||
stats.totalWords += f.content.split(/\s+/).length;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error fetching fragments:', e);
|
||||
error = e instanceof Error ? e.message : 'Unknown error';
|
||||
}
|
||||
|
||||
// Group by type
|
||||
const byType = fragments.reduce((acc: any, frag: any) => {
|
||||
const type = frag.fragment_type || 'general';
|
||||
if (!acc[type]) acc[type] = [];
|
||||
acc[type].push(frag);
|
||||
return acc;
|
||||
}, {});
|
||||
---
|
||||
|
||||
<AdminLayout title="Content Fragments">
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="spark-heading text-3xl">📦 Content Fragments</h1>
|
||||
<p class="text-silver mt-1">Reusable content blocks for article assembly</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button class="spark-btn-secondary text-sm" onclick="window.dispatchEvent(new CustomEvent('import-modal'))">
|
||||
📥 Import
|
||||
</button>
|
||||
<button class="spark-btn-secondary text-sm" onclick="window.dispatchEvent(new CustomEvent('export-data', {detail: {collection: 'content_fragments'}}))">
|
||||
📤 Export
|
||||
</button>
|
||||
<a href="/admin/collections/content-fragments/new" class="spark-btn-primary text-sm">
|
||||
✨ New Fragment
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div class="spark-card p-4 border-red-500 text-red-400">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Total Fragments</div>
|
||||
<div class="spark-data text-3xl">{stats.total}</div>
|
||||
</div>
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Total Words</div>
|
||||
<div class="spark-data text-3xl text-gold">{stats.totalWords.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Fragment Types</div>
|
||||
<div class="spark-data text-3xl text-blue-400">{Object.keys(stats.byType).length}</div>
|
||||
</div>
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Avg Words</div>
|
||||
<div class="spark-data text-3xl text-green-400">
|
||||
{stats.total > 0 ? Math.round(stats.totalWords / stats.total) : 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fragments by Type -->
|
||||
<div class="space-y-6">
|
||||
{Object.entries(byType).map(([type, items]: [string, any]) => (
|
||||
<div class="spark-card overflow-hidden">
|
||||
<div class="p-4 border-b border-edge-subtle bg-graphite">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-white font-semibold capitalize">{type}</h3>
|
||||
<span class="spark-label">{items.length} fragments</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-edge-subtle">
|
||||
{items.map((fragment: any) => (
|
||||
<div class="p-6 hover:bg-black/20 transition-colors">
|
||||
<div class="flex items-start justify-between gap-4 mb-3">
|
||||
<div class="flex-1">
|
||||
<h4 class="text-white font-medium">
|
||||
{fragment.title || `${type} Fragment`}
|
||||
</h4>
|
||||
{fragment.description && (
|
||||
<p class="text-silver/70 text-sm mt-1">{fragment.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{fragment.content && (
|
||||
<span class="px-2 py-1 bg-blue-500/10 text-blue-400 text-xs rounded">
|
||||
{fragment.content.split(/\s+/).length} words
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fragment.content && (
|
||||
<div class="p-4 bg-black/30 rounded border border-edge-subtle mb-3">
|
||||
<div class="text-silver text-sm leading-relaxed">
|
||||
{fragment.content.substring(0, 300)}
|
||||
{fragment.content.length > 300 && (
|
||||
<span class="text-silver/50">... <button class="text-gold hover:underline text-xs">read more</button></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fragment.variables && Array.isArray(fragment.variables) && fragment.variables.length > 0 && (
|
||||
<div class="mb-3">
|
||||
<div class="spark-label mb-1">Variables:</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{fragment.variables.map((variable: string) => (
|
||||
<span class="px-2 py-0.5 bg-purple-500/10 text-purple-400 text-xs rounded font-mono">
|
||||
{'{{'}{variable}{'}}'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="spark-btn-ghost text-xs px-3 py-1"
|
||||
onclick={`navigator.clipboard.writeText(${JSON.stringify(fragment.content || '')})`}
|
||||
>
|
||||
📋 Copy
|
||||
</button>
|
||||
<a href={`/admin/collections/content-fragments/${fragment.id}`} class="spark-btn-ghost text-xs px-3 py-1">
|
||||
Edit
|
||||
</a>
|
||||
<button class="spark-btn-secondary text-xs px-3 py-1">
|
||||
🔄 Generate Variation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{fragments.length === 0 && !error && (
|
||||
<div class="spark-card p-12 text-center">
|
||||
<p class="text-silver/50">No content fragments found. Start building your content library!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
<script>
|
||||
window.addEventListener('export-data', async (e: any) => {
|
||||
const { collection } = e.detail;
|
||||
const response = await fetch(`/api/collections/${collection}/export`);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${collection}-${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.click();
|
||||
});
|
||||
</script>
|
||||
195
frontend/src/pages/admin/collections/generation-jobs.astro
Normal file
195
frontend/src/pages/admin/collections/generation-jobs.astro
Normal file
@@ -0,0 +1,195 @@
|
||||
---
|
||||
/**
|
||||
* Generation Jobs Management
|
||||
* Queue monitoring and job management for content_fragments collection
|
||||
*/
|
||||
import AdminLayout from '@/layouts/AdminLayout.astro';
|
||||
import { getDirectusClient } from '@/lib/directus/client';
|
||||
import { readItems } from '@directus/sdk';
|
||||
|
||||
const client = getDirectusClient();
|
||||
|
||||
let jobs = [];
|
||||
let error = null;
|
||||
let stats = {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
processing: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
jobs = await client.request(readItems('generation_jobs', {
|
||||
fields: ['*'],
|
||||
sort: ['-date_created'],
|
||||
limit: 100,
|
||||
}));
|
||||
|
||||
stats.total = jobs.length;
|
||||
stats.pending = jobs.filter((j: any) => j.status === 'pending').length;
|
||||
stats.processing = jobs.filter((j: any) => j.status === 'processing').length;
|
||||
stats.completed = jobs.filter((j: any) => j.status === 'completed').length;
|
||||
stats.failed = jobs.filter((j: any) => j.status === 'failed').length;
|
||||
} catch (e) {
|
||||
console.error('Error fetching jobs:', e);
|
||||
error = e instanceof Error ? e.message : 'Unknown error';
|
||||
}
|
||||
---
|
||||
|
||||
<AdminLayout title="Generation Jobs">
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="spark-heading text-3xl">⚙️ Generation Jobs</h1>
|
||||
<p class="text-silver mt-1">Content generation queue monitoring</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button class="spark-btn-secondary text-sm" onclick="location.reload()">
|
||||
🔄 Refresh
|
||||
</button>
|
||||
<button class="spark-btn-secondary text-sm" onclick="window.dispatchEvent(new CustomEvent('export-data', {detail: {collection: 'generation_jobs'}}))">
|
||||
📤 Export
|
||||
</button>
|
||||
<button class="spark-btn-primary text-sm" onclick="window.dispatchEvent(new CustomEvent('clear-completed'))">
|
||||
🧹 Clear Completed
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div class="spark-card p-4 border-red-500 text-red-400">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-5 gap-4">
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Total Jobs</div>
|
||||
<div class="spark-data text-3xl">{stats.total}</div>
|
||||
</div>
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Pending</div>
|
||||
<div class="spark-data text-3xl text-yellow-400">{stats.pending}</div>
|
||||
</div>
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Processing</div>
|
||||
<div class="spark-data text-3xl text-blue-400 animate-pulse">{stats.processing}</div>
|
||||
</div>
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Completed</div>
|
||||
<div class="spark-data text-3xl text-green-400">{stats.completed}</div>
|
||||
</div>
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Failed</div>
|
||||
<div class="spark-data text-3xl text-red-400">{stats.failed}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Jobs Table -->
|
||||
<div class="spark-card overflow-hidden">
|
||||
<div class="p-6 border-b border-edge-subtle">
|
||||
<h2 class="text-white font-semibold">Recent Jobs</h2>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-graphite">
|
||||
<tr>
|
||||
<th class="text-left px-6 py-3 spark-label">Job ID</th>
|
||||
<th class="text-left px-6 py-3 spark-label">Type</th>
|
||||
<th class="text-left px-6 py-3 spark-label">Status</th>
|
||||
<th class="text-left px-6 py-3 spark-label">Progress</th>
|
||||
<th class="text-left px-6 py-3 spark-label">Created</th>
|
||||
<th class="text-right px-6 py-3 spark-label">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jobs.map((job: any, index: number) => (
|
||||
<tr class={index % 2 === 0 ? 'bg-black/20' : ''}>
|
||||
<td class="px-6 py-4">
|
||||
<code class="text-xs text-silver/70">{job.id.slice(0, 8)}...</code>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-white">{job.job_type || 'Article'}</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class={`px-2 py-1 rounded text-xs ${
|
||||
job.status === 'completed' ? 'bg-green-500/20 text-green-400' :
|
||||
job.status === 'processing' ? 'bg-blue-500/20 text-blue-400 animate-pulse' :
|
||||
job.status === 'pending' ? 'bg-yellow-500/20 text-yellow-400' :
|
||||
job.status === 'failed' ? 'bg-red-500/20 text-red-400' :
|
||||
'bg-graphite text-silver'
|
||||
}`}>
|
||||
{job.status || 'pending'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 bg-graphite rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
class={`h-full ${
|
||||
job.status === 'completed' ? 'bg-green-500' :
|
||||
job.status === 'processing' ? 'bg-blue-500' :
|
||||
job.status === 'failed' ? 'bg-red-500' :
|
||||
'bg-yellow-500'
|
||||
}`}
|
||||
style={`width: ${job.progress || 0}%`}
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-xs text-silver w-12 text-right">{job.progress || 0}%</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-silver text-sm">
|
||||
{job.date_created ? new Date(job.date_created).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}) : 'Unknown'}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
{job.status === 'failed' && (
|
||||
<button class="spark-btn-ghost text-xs px-3 py-1" onclick={`alert('Error: ${job.error_message || 'Unknown error'}')`}>
|
||||
View Error
|
||||
</button>
|
||||
)}
|
||||
{job.status === 'completed' && job.output_id && (
|
||||
<a href={`/admin/seo/articles/${job.output_id}`} class="spark-btn-ghost text-xs px-3 py-1">
|
||||
View Output
|
||||
</a>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{jobs.length === 0 && !error && (
|
||||
<div class="p-12 text-center">
|
||||
<p class="text-silver/50">No generation jobs found. Queue is empty!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
<script>
|
||||
window.addEventListener('export-data', async (e: any) => {
|
||||
const { collection } = e.detail;
|
||||
const response = await fetch(`/api/collections/${collection}/export`);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${collection}-${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.click();
|
||||
});
|
||||
|
||||
window.addEventListener('clear-completed', async () => {
|
||||
if (!confirm('Delete all completed jobs?')) return;
|
||||
// TODO: API endpoint to delete completed jobs
|
||||
alert('Feature coming soon!');
|
||||
});
|
||||
</script>
|
||||
145
frontend/src/pages/admin/collections/geo-intelligence.astro
Normal file
145
frontend/src/pages/admin/collections/geo-intelligence.astro
Normal file
@@ -0,0 +1,145 @@
|
||||
---
|
||||
/**
|
||||
* Geo Intelligence Management
|
||||
* Location targeting and geographic data
|
||||
*/
|
||||
import AdminLayout from '@/layouts/AdminLayout.astro';
|
||||
import { getDirectusClient } from '@/lib/directus/client';
|
||||
import { readItems } from '@directus/sdk';
|
||||
|
||||
const client = getDirectusClient();
|
||||
|
||||
let locations = [];
|
||||
let error = null;
|
||||
let stats = {
|
||||
total: 0,
|
||||
cities: 0,
|
||||
states: 0,
|
||||
countries: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
locations = await client.request(readItems('geo_intelligence', {
|
||||
fields: ['*'],
|
||||
sort: ['state', 'city'],
|
||||
}));
|
||||
|
||||
stats.total = locations.length;
|
||||
stats.cities = new Set(locations.map((l: any) => l.city)).size;
|
||||
stats.states = new Set(locations.map((l: any) => l.state)).size;
|
||||
stats.countries = new Set(locations.map((l: any) => l.country)).size;
|
||||
} catch (e) {
|
||||
console.error('Error fetching locations:', e);
|
||||
error = e instanceof Error ? e.message : 'Unknown error';
|
||||
}
|
||||
|
||||
// Group locations by state
|
||||
const byState = locations.reduce((acc: any, loc: any) => {
|
||||
if (!acc[loc.state]) acc[loc.state] = [];
|
||||
acc[loc.state].push(loc);
|
||||
return acc;
|
||||
}, {});
|
||||
---
|
||||
|
||||
<AdminLayout title="Geo Intelligence">
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="spark-heading text-3xl">🗺️ Geo Intelligence</h1>
|
||||
<p class="text-silver mt-1">Location targeting and geographic data</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button class="spark-btn-secondary text-sm" onclick="window.dispatchEvent(new CustomEvent('import-modal'))">
|
||||
📥 Import CSV
|
||||
</button>
|
||||
<button class="spark-btn-secondary text-sm" onclick="window.dispatchEvent(new CustomEvent('export-data', {detail: {collection: 'geo_intelligence'}}))">
|
||||
📤 Export
|
||||
</button>
|
||||
<a href="/admin/collections/geo-intelligence/new" class="spark-btn-primary text-sm">
|
||||
✨ New Location
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div class="spark-card p-4 border-red-500 text-red-400">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Total Locations</div>
|
||||
<div class="spark-data text-3xl">{stats.total}</div>
|
||||
</div>
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Cities</div>
|
||||
<div class="spark-data text-3xl text-blue-400">{stats.cities}</div>
|
||||
</div>
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">States</div>
|
||||
<div class="spark-data text-3xl text-green-400">{stats.states}</div>
|
||||
</div>
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Countries</div>
|
||||
<div class="spark-data text-3xl text-purple-400">{stats.countries}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Locations by State -->
|
||||
<div class="space-y-4">
|
||||
{Object.entries(byState).map(([state, locs]: [string, any]) => (
|
||||
<div class="spark-card overflow-hidden">
|
||||
<div class="p-4 border-b border-edge-subtle bg-graphite">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-white font-semibold">{state}</h3>
|
||||
<span class="spark-label">{locs.length} locations</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 p-6">
|
||||
{locs.map((loc: any) => (
|
||||
<div class="p-4 bg-black/20 rounded border border-edge-subtle hover:border-gold/30 transition-colors">
|
||||
<div class="text-white font-medium">{loc.city}</div>
|
||||
{loc.zip && <div class="text-silver/50 text-sm">{loc.zip}</div>}
|
||||
{loc.population && (
|
||||
<div class="text-silver text-xs mt-1">
|
||||
Pop: {loc.population.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
{loc.cluster && (
|
||||
<div class="mt-2">
|
||||
<span class="px-2 py-0.5 bg-gold/10 text-gold text-xs rounded">
|
||||
{loc.cluster}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{locations.length === 0 && !error && (
|
||||
<div class="spark-card p-12 text-center">
|
||||
<p class="text-silver/50">No locations found. Import your geo data!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
<script>
|
||||
window.addEventListener('export-data', async (e: any) => {
|
||||
const { collection } = e.detail;
|
||||
const response = await fetch(`/api/collections/${collection}/export`);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${collection}-${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.click();
|
||||
});
|
||||
</script>
|
||||
138
frontend/src/pages/admin/collections/headline-inventory.astro
Normal file
138
frontend/src/pages/admin/collections/headline-inventory.astro
Normal file
@@ -0,0 +1,138 @@
|
||||
---
|
||||
/**
|
||||
* Headline Inventory Management
|
||||
* Pre-written headlines library with spintax support
|
||||
*/
|
||||
import AdminLayout from '@/layouts/AdminLayout.astro';
|
||||
import { getDirectusClient } from '@/lib/directus/client';
|
||||
import { readItems } from '@directus/sdk';
|
||||
|
||||
const client = getDirectusClient();
|
||||
|
||||
let headlines = [];
|
||||
let error = null;
|
||||
let stats = {
|
||||
total: 0,
|
||||
byCategory: {} as Record<string, number>,
|
||||
};
|
||||
|
||||
try {
|
||||
headlines = await client.request(readItems('headline_inventory', {
|
||||
fields: ['*'],
|
||||
sort: ['-date_created'],
|
||||
limit: 200,
|
||||
}));
|
||||
|
||||
stats.total = headlines.length;
|
||||
headlines.forEach((h: any) => {
|
||||
const cat = h.category || 'uncategorized';
|
||||
stats.byCategory[cat] = (stats.byCategory[cat] || 0) + 1;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error fetching headlines:', e);
|
||||
error = e instanceof Error ? e.message : 'Unknown error';
|
||||
}
|
||||
---
|
||||
|
||||
<AdminLayout title="Headline Inventory">
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="spark-heading text-3xl">💬 Headline Inventory</h1>
|
||||
<p class="text-silver mt-1">Pre-written headlines library with spintax variations</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button class="spark-btn-secondary text-sm" onclick="window.dispatchEvent(new CustomEvent('import-modal'))">
|
||||
📥 Import
|
||||
</button>
|
||||
<button class="spark-btn-secondary text-sm" onclick="window.dispatchEvent(new CustomEvent('export-data', {detail: {collection: 'headline_inventory'}}))">
|
||||
📤 Export
|
||||
</button>
|
||||
<a href="/admin/collections/headline-inventory/new" class="spark-btn-primary text-sm">
|
||||
✨ New Headline
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div class="spark-card p-4 border-red-500 text-red-400">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Total Headlines</div>
|
||||
<div class="spark-data text-3xl">{stats.total}</div>
|
||||
</div>
|
||||
{Object.entries(stats.byCategory).slice(0, 3).map(([cat, count]) => (
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">{cat}</div>
|
||||
<div class="spark-data text-3xl text-gold">{count}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- Headlines List -->
|
||||
<div class="space-y-3">
|
||||
{headlines.map((headline: any) => (
|
||||
<div class="spark-card spark-card-hover p-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="px-2 py-0.5 bg-gold/10 text-gold text-xs rounded">
|
||||
{headline.category || 'general'}
|
||||
</span>
|
||||
{headline.spintax_count && (
|
||||
<span class="px-2 py-0.5 bg-blue-500/10 text-blue-400 text-xs rounded">
|
||||
{headline.spintax_count} variations
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div class="text-white font-medium mb-1 leading-tight">
|
||||
{headline.headline_text}
|
||||
</div>
|
||||
{headline.preview && (
|
||||
<div class="text-silver/50 text-sm">
|
||||
Example: {headline.preview}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="flex gap-2 flex-shrink-0">
|
||||
<button
|
||||
class="spark-btn-ghost text-xs px-3 py-1"
|
||||
onclick={`navigator.clipboard.writeText(${JSON.stringify(headline.headline_text)})`}
|
||||
>
|
||||
📋 Copy
|
||||
</button>
|
||||
<a href={`/admin/collections/headline-inventory/${headline.id}`} class="spark-btn-ghost text-xs px-3 py-1">
|
||||
Edit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{headlines.length === 0 && !error && (
|
||||
<div class="spark-card p-12 text-center">
|
||||
<p class="text-silver/50">No headlines found. Start building your library!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
<script>
|
||||
window.addEventListener('export-data', async (e: any) => {
|
||||
const { collection } = e.detail;
|
||||
const response = await fetch(`/api/collections/${collection}/export`);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${collection}-${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.click();
|
||||
});
|
||||
</script>
|
||||
162
frontend/src/pages/admin/collections/offer-blocks.astro
Normal file
162
frontend/src/pages/admin/collections/offer-blocks.astro
Normal file
@@ -0,0 +1,162 @@
|
||||
---
|
||||
/** * Offer Blocks Management
|
||||
* Call-to-action templates and offer messaging
|
||||
*/
|
||||
import AdminLayout from '@/layouts/AdminLayout.astro';
|
||||
import { getDirectusClient } from '@/lib/directus/client';
|
||||
import { readItems } from '@directus/sdk';
|
||||
|
||||
const client = getDirectusClient();
|
||||
|
||||
let offers = [];
|
||||
let error = null;
|
||||
let stats = {
|
||||
total: 0,
|
||||
byType: {} as Record<string, number>,
|
||||
};
|
||||
|
||||
try {
|
||||
offers = await client.request(readItems('offer_blocks', {
|
||||
fields: ['*'],
|
||||
sort: ['offer_type', 'title'],
|
||||
}));
|
||||
|
||||
stats.total = offers.length;
|
||||
offers.forEach((o: any) => {
|
||||
const type = o.offer_type || 'general';
|
||||
stats.byType[type] = (stats.byType[type] || 0) + 1;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error fetching offers:', e);
|
||||
error = e instanceof Error ? e.message : 'Unknown error';
|
||||
}
|
||||
---
|
||||
|
||||
<AdminLayout title="Offer Blocks">
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="spark-heading text-3xl">🎯 Offer Blocks</h1>
|
||||
<p class="text-silver mt-1">Call-to-action templates and offer messaging</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button class="spark-btn-secondary text-sm" onclick="window.dispatchEvent(new CustomEvent('import-modal'))">
|
||||
📥 Import
|
||||
</button>
|
||||
<button class="spark-btn-secondary text-sm" onclick="window.dispatchEvent(new CustomEvent('export-data', {detail: {collection: 'offer_blocks'}}))">
|
||||
📤 Export
|
||||
</button>
|
||||
<a href="/admin/collections/offer-blocks/new" class="spark-btn-primary text-sm">
|
||||
✨ New Offer
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div class="spark-card p-4 border-red-500 text-red-400">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Total Offers</div>
|
||||
<div class="spark-data text-3xl">{stats.total}</div>
|
||||
</div>
|
||||
{Object.entries(stats.byType).slice(0, 4).map(([type, count]) => (
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">{type}</div>
|
||||
<div class="spark-data text-3xl text-gold">{count}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<!-- Offers Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{offers.map((offer: any) => (
|
||||
<div class="spark-card spark-card-hover p-6">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 class="text-white font-semibold text-lg">{offer.title || 'Unnamed Offer'}</h3>
|
||||
{offer.slug && (
|
||||
<code class="text-xs text-silver/50 mt-1 block">{offer.slug}</code>
|
||||
)}
|
||||
</div>
|
||||
<span class="px-3 py-1 bg-gold/10 text-gold text-xs rounded border border-gold/30">
|
||||
{offer.offer_type || 'general'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{offer.hook && (
|
||||
<div class="mb-3 p-3 bg-black/30 rounded border border-edge-subtle">
|
||||
<div class="spark-label mb-1">Hook:</div>
|
||||
<div class="text-silver text-sm leading-relaxed">
|
||||
{offer.hook.substring(0, 150)}{offer.hook.length > 150 ? '...' : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{offer.offer_text && (
|
||||
<div class="mb-4 p-3 bg-black/30 rounded border border-edge-subtle">
|
||||
<div class="spark-label mb-1">Offer Text:</div>
|
||||
<div class="text-white font-medium text-sm leading-relaxed">
|
||||
{offer.offer_text.substring(0, 120)}{offer.offer_text.length > 120 ? '...' : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{offer.avatar_pains && Array.isArray(offer.avatar_pains) && (
|
||||
<div class="mb-4">
|
||||
<div class="spark-label mb-2">Target Pains:</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{offer.avatar_pains.slice(0, 3).map((pain: string) => (
|
||||
<span class="px-2 py-0.5 bg-red-500/10 text-red-400 text-xs rounded border border-red-500/20">
|
||||
{pain}
|
||||
</span>
|
||||
))}
|
||||
{offer.avatar_pains.length > 3 && (
|
||||
<span class="px-2 py-0.5 text-xs text-silver/50">
|
||||
+{offer.avatar_pains.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="spark-btn-ghost text-xs px-3 py-1 flex-1"
|
||||
onclick={`navigator.clipboard.writeText(${JSON.stringify(offer.offer_text || offer.hook || '')})`}
|
||||
>
|
||||
📋 Copy
|
||||
</button>
|
||||
<a href={`/admin/collections/offer-blocks/${offer.id}`} class="spark-btn-ghost text-xs px-3 py-1 flex-1 text-center">
|
||||
Edit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{offers.length === 0 && !error && (
|
||||
<div class="col-span-full spark-card p-12 text-center">
|
||||
<p class="text-silver/50">No offers found. Create your first offer block!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
<script>
|
||||
window.addEventListener('export-data', async (e: any) => {
|
||||
const { collection } = e.detail;
|
||||
const response = await fetch(`/api/collections/${collection}/export`);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${collection}-${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.click();
|
||||
});
|
||||
</script>
|
||||
179
frontend/src/pages/admin/collections/spintax-dictionaries.astro
Normal file
179
frontend/src/pages/admin/collections/spintax-dictionaries.astro
Normal file
@@ -0,0 +1,179 @@
|
||||
---
|
||||
/**
|
||||
* Spintax Dictionaries Management
|
||||
* Word variation sets for content spinning
|
||||
*/
|
||||
import AdminLayout from '@/layouts/AdminLayout.astro';
|
||||
import { getDirectusClient } from '@/lib/directus/client';
|
||||
import { readItems } from '@directus/sdk';
|
||||
|
||||
const client = getDirectusClient();
|
||||
|
||||
let dictionaries = [];
|
||||
let error = null;
|
||||
let stats = {
|
||||
total: 0,
|
||||
totalWords: 0,
|
||||
byCategory: {} as Record<string, number>,
|
||||
};
|
||||
|
||||
try {
|
||||
dictionaries = await client.request(readItems('spintax_dictionaries', {
|
||||
fields: ['*'],
|
||||
sort: ['category', 'base_word'],
|
||||
}));
|
||||
|
||||
stats.total = dictionaries.length;
|
||||
dictionaries.forEach((d: any) => {
|
||||
const cat = d.category || 'general';
|
||||
stats.byCategory[cat] = (stats.byCategory[cat] || 0) + 1;
|
||||
if (d.variations && Array.isArray(d.variations)) {
|
||||
stats.totalWords += d.variations.length;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error fetching dictionaries:', e);
|
||||
error = e instanceof Error ? e.message : 'Unknown error';
|
||||
}
|
||||
|
||||
// Group by category
|
||||
const byCategory = dictionaries.reduce((acc: any, dict: any) => {
|
||||
const cat = dict.category || 'general';
|
||||
if (!acc[cat]) acc[cat] = [];
|
||||
acc[cat].push(dict);
|
||||
return acc;
|
||||
}, {});
|
||||
---
|
||||
|
||||
<AdminLayout title="Spintax Dictionaries">
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="spark-heading text-3xl">📚 Spintax Dictionaries</h1>
|
||||
<p class="text-silver mt-1">Word variation sets for content spinning</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button class="spark-btn-secondary text-sm" onclick="window.dispatchEvent(new CustomEvent('import-modal'))">
|
||||
📥 Import
|
||||
</button>
|
||||
<button class="spark-btn-secondary text-sm" onclick="window.dispatchEvent(new CustomEvent('export-data', {detail: {collection: 'spintax_dictionaries'}}))">
|
||||
📤 Export
|
||||
</button>
|
||||
<a href="/admin/collections/spintax-dictionaries/new" class="spark-btn-primary text-sm">
|
||||
✨ New Entry
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div class="spark-card p-4 border-red-500 text-red-400">
|
||||
<strong>Error:</strong> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Total Entries</div>
|
||||
<div class="spark-data text-3xl">{stats.total}</div>
|
||||
</div>
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Total Words</div>
|
||||
<div class="spark-data text-3xl text-gold">{stats.totalWords}</div>
|
||||
</div>
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Categories</div>
|
||||
<div class="spark-data text-3xl text-blue-400">{Object.keys(stats.byCategory).length}</div>
|
||||
</div>
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Avg Variations</div>
|
||||
<div class="spark-data text-3xl text-green-400">
|
||||
{stats.total > 0 ? Math.round(stats.totalWords / stats.total) : 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dictionaries by Category -->
|
||||
<div class="space-y-6">
|
||||
{Object.entries(byCategory).map(([category, items]: [string, any]) => (
|
||||
<div class="spark-card overflow-hidden">
|
||||
<div class="p-4 border-b border-edge-subtle bg-graphite">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-white font-semibold capitalize">{category}</h3>
|
||||
<span class="spark-label">{items.length} entries</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 space-y-3">
|
||||
{items.map((dict: any) => (
|
||||
<div class="p-4 bg-black/20 rounded border border-edge-subtle hover:border-gold/30 transition-colors">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-white font-medium">{dict.base_word}</span>
|
||||
{dict.variations && Array.isArray(dict.variations) && (
|
||||
<span class="px-2 py-0.5 bg-gold/10 text-gold text-xs rounded">
|
||||
{dict.variations.length} variations
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{dict.variations && Array.isArray(dict.variations) && (
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{dict.variations.slice(0, 10).map((variation: string) => (
|
||||
<span class="px-2 py-0.5 bg-graphite border border-edge-subtle rounded text-xs text-silver">
|
||||
{variation}
|
||||
</span>
|
||||
))}
|
||||
{dict.variations.length > 10 && (
|
||||
<span class="px-2 py-0.5 text-xs text-silver/50">
|
||||
+{dict.variations.length - 10} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{dict.spintax_format && (
|
||||
<div class="mt-2 p-2 bg-black/40 rounded">
|
||||
<code class="text-xs text-green-400">{dict.spintax_format}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div class="flex gap-2 flex-shrink-0">
|
||||
<button
|
||||
class="spark-btn-ghost text-xs px-3 py-1"
|
||||
onclick={`navigator.clipboard.writeText(${JSON.stringify(dict.spintax_format || dict.base_word)})`}
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
<a href={`/admin/collections/spintax-dictionaries/${dict.id}`} class="spark-btn-ghost text-xs px-3 py-1">
|
||||
Edit
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{dictionaries.length === 0 && !error && (
|
||||
<div class="spark-card p-12 text-center">
|
||||
<p class="text-silver/50">No spintax dictionaries found. Start building your word library!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
<script>
|
||||
window.addEventListener('export-data', async (e: any) => {
|
||||
const { collection } = e.detail;
|
||||
const response = await fetch(`/api/collections/${collection}/export`);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${collection}-${new Date().toISOString().split('T')[0]}.json`;
|
||||
a.click();
|
||||
});
|
||||
</script>
|
||||
@@ -1,20 +1,90 @@
|
||||
---
|
||||
/**
|
||||
* Leads Management
|
||||
* Customer lead tracking and management
|
||||
*/
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
import LeadList from '@/components/admin/leads/LeadList';
|
||||
import { getDirectusClient } from '@/lib/directus/client';
|
||||
import { readItems } from '@directus/sdk';
|
||||
|
||||
const client = getDirectusClient();
|
||||
|
||||
let stats = {
|
||||
total: 0,
|
||||
new: 0,
|
||||
contacted: 0,
|
||||
qualified: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
const leads = await client.request(readItems('leads', {
|
||||
fields: ['status'],
|
||||
}));
|
||||
|
||||
stats.total = leads.length;
|
||||
stats.new = leads.filter((l: any) => l.status === 'new').length;
|
||||
stats.contacted = leads.filter((l: any) => l.status === 'contacted').length;
|
||||
stats.qualified = leads.filter((l: any) => l.status === 'qualified').length;
|
||||
} catch (e) {
|
||||
console.error('Error fetching lead stats:', e);
|
||||
}
|
||||
---
|
||||
|
||||
<Layout title="Leads & Inquiries">
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-slate-100">Leads</h1>
|
||||
<p class="text-slate-400">View form submissions and inquiries.</p>
|
||||
<h1 class="spark-heading text-3xl">👤 Leads</h1>
|
||||
<p class="text-silver mt-1">Customer lead tracking and management</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button class="spark-btn-secondary text-sm" onclick="window.dispatchEvent(new CustomEvent('export-data', {detail: {collection: 'leads'}}))">
|
||||
📤 Export CSV
|
||||
</button>
|
||||
<a href="/admin/leads/new" class="spark-btn-primary text-sm">
|
||||
✨ Add Lead
|
||||
</a>
|
||||
</div>
|
||||
<button class="bg-gray-700 hover:bg-gray-600 text-white px-4 py-2 rounded-lg font-medium transition-colors">
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<LeadList client:load />
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Total Leads</div>
|
||||
<div class="spark-data text-3xl">{stats.total}</div>
|
||||
</div>
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">New</div>
|
||||
<div class="spark-data text-3xl text-yellow-400">{stats.new}</div>
|
||||
</div>
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Contacted</div>
|
||||
<div class="spark-data text-3xl text-blue-400">{stats.contacted}</div>
|
||||
</div>
|
||||
<div class="spark-card p-6">
|
||||
<div class="spark-label mb-2">Qualified</div>
|
||||
<div class="spark-data text-3xl text-green-400">{stats.qualified}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Leads List -->
|
||||
<div class="spark-card overflow-hidden">
|
||||
<LeadList client:load />
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
window.addEventListener('export-data', async (e: any) => {
|
||||
const { collection } = e.detail;
|
||||
const response = await fetch(`/api/collections/${collection}/export`);
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${collection}-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.click();
|
||||
});
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user