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 Layout from '@/layouts/AdminLayout.astro';
|
||||||
import LeadList from '@/components/admin/leads/LeadList';
|
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">
|
<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 class="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-slate-100">Leads</h1>
|
<h1 class="spark-heading text-3xl">👤 Leads</h1>
|
||||||
<p class="text-slate-400">View form submissions and inquiries.</p>
|
<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>
|
</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>
|
</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>
|
</div>
|
||||||
</Layout>
|
</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