God Mode: Sync All Admin Pages & Components. Fix Navigation. Fix Schemas.

This commit is contained in:
cawcenter
2025-12-14 19:28:20 -05:00
parent 189abfb384
commit e97bdee388
112 changed files with 5035 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { AreaChart, DonutChart, BarChart } from '@tremor/react';
const chartdata = [
{ date: 'Jan 22', Organic: 2890, Paid: 2338 },
{ date: 'Feb 22', Organic: 2756, Paid: 2103 },
{ date: 'Mar 22', Organic: 3322, Paid: 2194 },
{ date: 'Apr 22', Organic: 3470, Paid: 2108 },
{ date: 'May 22', Organic: 3475, Paid: 1812 },
{ date: 'Jun 22', Organic: 3129, Paid: 1726 },
];
const trafficSource = [
{ name: 'Google Search', value: 9800 },
{ name: 'Direct', value: 4567 },
{ name: 'Social', value: 3908 },
{ name: 'Referral', value: 2400 },
];
const valueFormatter = (number: number) => `$ ${new Intl.NumberFormat('us').format(number).toString()}`;
export const MetricsDashboard = () => (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* KPI 1: Traffic Growth */}
<Card className="col-span-1 lg:col-span-2 bg-card/50 backdrop-blur border-border/50 p-6">
<h3 className="text-tremor-content-strong dark:text-dark-tremor-content-strong font-medium">Traffic Growth & Sources</h3>
<AreaChart
className="mt-4 h-72"
data={chartdata}
index="date"
categories={['Organic', 'Paid']}
colors={['indigo', 'rose']}
yAxisWidth={60}
onValueChange={(v) => console.log(v)}
showAnimation={true}
/>
</Card>
{/* KPI 2: Source Breakdown */}
<Card className="col-span-1 bg-card/50 backdrop-blur border-border/50 p-6">
<h3 className="text-tremor-content-strong dark:text-dark-tremor-content-strong font-medium">Traffic Sources</h3>
<DonutChart
className="mt-6"
data={trafficSource}
category="value"
index="name"
valueFormatter={valueFormatter}
colors={['slate', 'violet', 'indigo', 'rose']}
showAnimation={true}
/>
</Card>
{/* KPI 3: Engagement */}
<Card className="col-span-1 lg:col-span-3 bg-card/50 backdrop-blur border-border/50 p-6">
<h3 className="text-tremor-content-strong dark:text-dark-tremor-content-strong font-medium">Monthly Active Users</h3>
<BarChart
className="mt-6 h-60"
data={chartdata}
index="date"
categories={['Organic', 'Paid']}
colors={['blue', 'teal']}
yAxisWidth={48}
showAnimation={true}
/>
</Card>
</div>
);

View File

View File

@@ -0,0 +1,143 @@
import React, { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Loader2, Play, Users, MapPin, Calendar } from 'lucide-react';
import { toast } from 'sonner';
const BulkGenerator = () => {
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
const [quantity, setQuantity] = useState(10);
const [jobStatus, setJobStatus] = useState<'idle' | 'running' | 'complete'>('idle');
const { data: templates } = useQuery({
queryKey: ['templates'],
queryFn: async () => {
const res = await fetch('/api/assembler/templates');
return res.json();
}
});
const runBulkJob = useMutation({
mutationFn: async () => {
// Placeholder for actual bulk job logic (Phase 5.b)
return new Promise((resolve) => setTimeout(resolve, 3000));
},
onMutate: () => setJobStatus('running'),
onSuccess: () => {
setJobStatus('complete');
toast.success(`Generated ${quantity} articles successfully!`);
}
});
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<Card className="md:col-span-1 p-6 space-y-6">
<div>
<h3 className="text-lg font-semibold mb-2">1. Select Template</h3>
<div className="space-y-2 max-h-60 overflow-y-auto pr-2">
{templates?.map((t: any) => (
<div
key={t.id}
onClick={() => setSelectedTemplate(t.id)}
className={`p-3 rounded-md border cursor-pointer transition-colors ${selectedTemplate === t.id
? 'bg-primary/10 border-primary'
: 'bg-card border-border hover:border-primary/50'
}`}
>
<div className="font-medium text-sm">{t.pattern_name}</div>
<div className="text-xs text-muted-foreground truncate">{t.structure_type || 'Custom'}</div>
</div>
))}
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-2">2. Data Source</h3>
<div className="grid grid-cols-2 gap-2">
<Button variant="outline" className="h-20 flex flex-col gap-2">
<Users className="h-6 w-6" />
<span className="text-xs">Profiles</span>
</Button>
<Button variant="outline" className="h-20 flex flex-col gap-2">
<MapPin className="h-6 w-6" />
<span className="text-xs">Geo Data</span>
</Button>
</div>
</div>
<div>
<h3 className="text-lg font-semibold mb-2">3. Quantity</h3>
<Input
type="number"
value={quantity}
onChange={(e) => setQuantity(Number(e.target.value))}
min={1}
max={1000}
/>
</div>
<Button
className="w-full"
size="lg"
disabled={!selectedTemplate || jobStatus === 'running'}
onClick={() => runBulkJob.mutate()}
>
{jobStatus === 'running' ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Generating...
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
Start Bulk Job
</>
)}
</Button>
</Card>
<div className="md:col-span-2">
<Card className="h-full flex flex-col">
<div className="p-4 border-b border-border/50">
<h3 className="font-semibold">Job Queue & Results</h3>
</div>
{jobStatus === 'complete' ? (
<div className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Article Title</TableHead>
<TableHead>Status</TableHead>
<TableHead>SEO Score</TableHead>
<TableHead>Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
<TableCell className="font-medium">Top 10 Plumbing Tips in New York</TableCell>
<TableCell><Badge className="bg-green-500/10 text-green-500">Ready</Badge></TableCell>
<TableCell>92/100</TableCell>
<TableCell className="text-muted-foreground">{new Date().toLocaleDateString()}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground">
<Calendar className="h-12 w-12 mb-4 opacity-20" />
<p>No jobs running</p>
</div>
)}
</Card>
</div>
</div>
);
};
export default BulkGenerator;

View File

@@ -0,0 +1,239 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Play, Save, RefreshCw, Wand2, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
interface Variable {
key: string;
value: string;
}
const TemplateComposer = () => {
const [template, setTemplate] = useState('');
const [variables, setVariables] = useState<Variable[]>([
{ key: 'city', value: 'New York' },
{ key: 'niche', value: 'Plumbing' },
{ key: 'service', value: 'Emergency Repair' }
]);
const [previewContent, setPreviewContent] = useState('');
const [templates, setTemplates] = useState<any[]>([]);
const [currentTemplateId, setCurrentTemplateId] = useState<string | null>(null);
const [templateName, setTemplateName] = useState('Untitled Template');
// Load templates on mount
const fetchTemplates = useQuery({
queryKey: ['templates'],
queryFn: async () => {
const res = await fetch('/api/assembler/templates');
return res.json();
}
});
const saveTemplate = useMutation({
mutationFn: async () => {
const res = await fetch('/api/assembler/templates', {
method: 'POST',
body: JSON.stringify({
id: currentTemplateId,
title: templateName,
content: template
})
});
return res.json();
},
onSuccess: (data) => {
toast.success('Template saved successfully!');
fetchTemplates.refetch();
if (data.id) setCurrentTemplateId(data.id);
},
onError: () => toast.error('Failed to save template')
});
const loadTemplate = (tmpl: any) => {
setTemplate(tmpl.pattern_structure || '');
setTemplateName(tmpl.pattern_name || 'Untitled');
setCurrentTemplateId(tmpl.id);
toast.info(`Loaded: ${tmpl.pattern_name}`);
};
const generatePreview = useMutation({
mutationFn: async () => {
const varMap = variables.reduce((acc, curr) => {
if (curr.key) acc[curr.key] = curr.value;
return acc;
}, {} as Record<string, string>);
const res = await fetch('/api/assembler/preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ template, variables: varMap })
});
if (!res.ok) throw new Error('Generation failed');
return res.json();
},
onSuccess: (data) => {
setPreviewContent(data.content);
toast.success('Preview generated successfully');
},
onError: () => {
toast.error('Failed to generate preview');
}
});
const addVariable = () => {
setVariables([...variables, { key: '', value: '' }]);
};
const updateVariable = (index: number, field: 'key' | 'value', val: string) => {
const newVars = [...variables];
newVars[index][field] = val;
setVariables(newVars);
};
const removeVariable = (index: number) => {
const newVars = [...variables];
newVars.splice(index, 1);
setVariables(newVars);
};
const insertVariable = (key: string) => {
setTemplate(prev => prev + `{{${key}}}`);
};
// Initialize with a demo template if empty
useEffect(() => {
if (!template) {
setTemplate("Welcome to {{city}}'s best {{niche}} provider!\n\nWe offer {premium|high-quality|top-rated} {{service}} for all residential customers.\n\n{Call us today|Contact us now|Get a quote} to learn more.");
}
}, []);
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 h-[calc(100vh-140px)]">
{/* Left Panel: Variables & Inputs */}
<div className="lg:col-span-3 flex flex-col gap-4 overflow-y-auto pr-2">
<Card className="bg-card/50 backdrop-blur-sm border-border/50 p-4">
<div className="flex items-center justify-between mb-4">
<Label className="font-bold text-base">Variables</Label>
<Button variant="ghost" size="sm" onClick={addVariable} className="h-8 w-8 p-0">
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="space-y-3">
{variables.map((v, i) => (
<div key={i} className="flex flex-col gap-2 p-2 rounded bg-background/50 border border-border/50 group">
<div className="flex items-center gap-2">
<Input
placeholder="Key (e.g. city)"
value={v.key}
onChange={(e) => updateVariable(i, 'key', e.target.value)}
className="h-8 text-xs font-mono"
/>
<Button
variant="ghost"
size="icon"
onClick={() => removeVariable(i)}
className="h-8 w-8 text-muted-foreground hover:text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<Input
placeholder="Value"
value={v.value}
onChange={(e) => updateVariable(i, 'value', e.target.value)}
className="h-8 text-xs"
/>
{v.key && (
<Button
variant="secondary"
size="sm"
className="h-6 text-[10px] w-full mt-1"
onClick={() => insertVariable(v.key)}
>
Insert {`{{${v.key}}}`}
</Button>
)}
</div>
))}
</div>
</Card>
</div>
{/* Center Panel: Template Editor */}
<div className="lg:col-span-5 flex flex-col">
<Card className="bg-card/50 backdrop-blur-sm border-border/50 flex flex-col h-full overflow-hidden">
<div className="p-3 border-b border-border/50 flex items-center justify-between bg-muted/20">
<div className="flex items-center gap-2">
<Wand2 className="h-4 w-4 text-purple-500" />
<Input
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
className="h-7 text-sm font-semibold bg-transparent border-transparent hover:border-border focus:border-border w-48"
/>
</div>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => saveTemplate.mutate()} disabled={saveTemplate.isPending}>
<Save className="h-4 w-4 mr-2" />
Save
</Button>
<Button size="sm" onClick={() => generatePreview.mutate()} disabled={generatePreview.isPending}>
{generatePreview.isPending ? (
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<Play className="h-4 w-4 mr-2 fill-current" />
)}
Generate
</Button>
</div>
</div>
<div className="flex-1 relative">
<Textarea
value={template}
onChange={(e) => setTemplate(e.target.value)}
className="absolute inset-0 w-full h-full resize-none rounded-none border-0 bg-transparent p-4 font-mono text-sm leading-relaxed focus-visible:ring-0"
placeholder="Write your content template here. Use {{variable}} for dynamic data and {option1|option2} for spintax."
/>
</div>
<div className="p-2 border-t border-border/50 bg-muted/20 text-xs text-muted-foreground flex justify-between">
<span>{template.length} chars</span>
<span>Markdown Supported</span>
</div>
</Card>
</div>
{/* Right Panel: Live Preview */}
<div className="lg:col-span-4 flex flex-col">
<Card className="bg-card/50 backdrop-blur-sm border-border/50 flex flex-col h-full overflow-hidden border-l-4 border-l-primary/50">
<div className="p-3 border-b border-border/50 bg-muted/20 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-semibold text-sm">Generated Output</span>
{previewContent && <span className="text-[10px] px-2 py-0.5 border rounded-full text-green-500 border-green-500/50 bg-green-500/10">Live</span>}
</div>
</div>
<div className="flex-1 p-6 overflow-y-auto bg-white/5 dark:bg-black/20">
{previewContent ? (
<div className="prose prose-sm dark:prose-invert max-w-none whitespace-pre-wrap">
{previewContent}
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-muted-foreground opacity-50">
<Play className="h-12 w-12 mb-4" />
<p>Click Generate to see preview</p>
</div>
)}
</div>
</Card>
</div>
</div>
);
};
export default TemplateComposer;

View File

@@ -0,0 +1,123 @@
import React, { useCallback } from 'react';
import ReactFlow, {
MiniMap,
Controls,
Background,
useNodesState,
useEdgesState,
addEdge,
Connection,
Edge,
MarkerType,
Node as ReactFlowNode
} from 'reactflow';
import 'reactflow/dist/style.css';
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Plus, Play, Save } from 'lucide-react';
import { toast } from 'sonner';
// Custom Node Styles
const nodeStyle = {
background: '#1e1e20',
color: '#fff',
border: '1px solid #3f3f46',
borderRadius: '8px',
padding: '10px',
minWidth: '150px',
fontSize: '12px',
};
const initialNodes = [
{ id: '1', position: { x: 250, y: 50 }, data: { label: '🚀 Start Trigger' }, style: { ...nodeStyle, border: '1px solid #eab308' } },
{ id: '2', position: { x: 250, y: 150 }, data: { label: '🔍 Fetch Keywords' }, style: nodeStyle },
{ id: '3', position: { x: 100, y: 250 }, data: { label: '📝 Generate Outline' }, style: nodeStyle },
{ id: '4', position: { x: 400, y: 250 }, data: { label: '🤖 Generate Content' }, style: nodeStyle },
{ id: '5', position: { x: 250, y: 350 }, data: { label: '✅ Publish to Site' }, style: { ...nodeStyle, border: '1px solid #22c55e' } },
];
const initialEdges = [
{ id: 'e1-2', source: '1', target: '2', animated: true, markerEnd: { type: MarkerType.ArrowClosed } },
{ id: 'e2-3', source: '2', target: '3', markerEnd: { type: MarkerType.ArrowClosed } },
{ id: 'e2-4', source: '2', target: '4', markerEnd: { type: MarkerType.ArrowClosed } },
{ id: 'e3-5', source: '3', target: '5', markerEnd: { type: MarkerType.ArrowClosed } },
{ id: 'e4-5', source: '4', target: '5', markerEnd: { type: MarkerType.ArrowClosed } },
];
const AutomationBuilder = () => {
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const onConnect = useCallback((params: Edge | Connection) => setEdges((eds) => addEdge(params, eds)), [setEdges]);
const onAddNode = () => {
const id = (nodes.length + 1).toString();
const newNode: ReactFlowNode = {
id,
position: { x: Math.random() * 500, y: Math.random() * 500 },
data: { label: `⚡ Action ${id}` },
style: nodeStyle
};
setNodes((nds) => nds.concat(newNode));
};
const onSave = () => {
// nodes and edges are already typed by useNodesState and useEdgesState
console.log('Flow saved:', { nodes: nodes as ReactFlowNode[], edges: edges as Edge[] });
toast.success("Automation Workflow Saved!");
}
return (
<div className="h-[calc(100vh-140px)] w-full flex flex-col gap-4">
{/* Toolbar */}
<Card className="p-3 flex justify-between items-center bg-card/50 backdrop-blur border-border/50">
<div className="flex gap-2">
<Button size="sm" onClick={onAddNode} variant="outline">
<Plus className="h-4 w-4 mr-2" /> Add Action
</Button>
<div className="h-8 w-[1px] bg-border mx-2"></div>
<div className="text-sm text-muted-foreground pt-1">
Drag nodes to connect actions. Right click to configure.
</div>
</div>
<div className="flex gap-2">
<Button size="sm" variant="secondary" onClick={onSave}>
<Save className="h-4 w-4 mr-2" /> Save Workflow
</Button>
<Button size="sm" className="bg-green-600 hover:bg-green-700">
<Play className="h-4 w-4 mr-2" /> Activate
</Button>
</div>
</Card>
{/* Canvas */}
<Card className="flex-1 overflow-hidden border-border/50 shadow-xl bg-[#111]">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
fitView
className="bg-black/20"
>
<Controls className="bg-card border-border fill-foreground" />
<MiniMap
nodeColor={(n) => {
if (n.id === '1') return '#eab308';
if (n.id === '5') return '#22c55e';
return '#3f3f46';
}}
maskColor="#00000080"
className="bg-card border-border"
/>
<Background color="#333" gap={16} />
</ReactFlow>
</Card>
</div>
);
};
export default AutomationBuilder;

View File

@@ -0,0 +1,56 @@
---
interface Column {
content: string;
width?: number;
}
interface Props {
title?: string;
columns: Column[];
gap?: 'sm' | 'md' | 'lg';
vertical_align?: 'top' | 'center' | 'bottom';
}
const {
title,
columns = [],
gap = 'md',
vertical_align = 'top'
} = Astro.props;
const gapClasses = {
sm: 'gap-4',
md: 'gap-8',
lg: 'gap-12'
};
const alignClasses = {
top: 'items-start',
center: 'items-center',
bottom: 'items-end'
};
const columnCount = columns.length;
const gridCols = columnCount === 1 ? 'grid-cols-1'
: columnCount === 2 ? 'md:grid-cols-2'
: columnCount === 3 ? 'md:grid-cols-3'
: 'md:grid-cols-4';
---
<section class="max-w-6xl mx-auto px-6 py-12">
{title && (
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-8 text-center">
{title}
</h2>
)}
<div class={`grid grid-cols-1 ${gridCols} ${gapClasses[gap]} ${alignClasses[vertical_align]}`}>
{columns.map((col) => (
<div
class="prose prose-gray dark:prose-invert max-w-none"
style={col.width ? `flex: 0 0 ${col.width}%` : ''}
set:html={col.content}
/>
))}
</div>
</section>

View File

@@ -0,0 +1,72 @@
---
interface FAQ {
question: string;
answer: string;
}
interface Props {
title?: string;
subtitle?: string;
faqs: FAQ[];
}
const { title, subtitle, faqs = [] } = Astro.props;
// Generate structured data for SEO
const structuredData = {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": faqs.map(faq => ({
"@type": "Question",
"name": faq.question,
"acceptedAnswer": {
"@type": "Answer",
"text": faq.answer
}
}))
};
---
<section class="max-w-4xl mx-auto px-6 py-16">
{(title || subtitle) && (
<div class="text-center mb-12">
{title && (
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{title}
</h2>
)}
{subtitle && (
<p class="text-xl text-gray-600 dark:text-gray-400">
{subtitle}
</p>
)}
</div>
)}
<div class="space-y-4">
{faqs.map((faq, index) => (
<details
class="group bg-white dark:bg-gray-800 rounded-xl shadow-md overflow-hidden border border-gray-200 dark:border-gray-700"
>
<summary class="flex items-center justify-between p-6 cursor-pointer list-none font-semibold text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<span class="pr-6">{faq.question}</span>
<svg
class="w-5 h-5 text-gray-500 transition-transform duration-300 group-open:rotate-180 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div class="px-6 pb-6 pt-2 text-gray-600 dark:text-gray-400 leading-relaxed border-t border-gray-100 dark:border-gray-700">
<div set:html={faq.answer} />
</div>
</details>
))}
</div>
<!-- FAQ Schema for SEO -->
<script type="application/ld+json" set:html={JSON.stringify(structuredData)} />
</section>

View File

@@ -0,0 +1,165 @@
---
import type { Form, FormField } from '@/types/schema';
interface Props {
form_id?: string;
title?: string;
subtitle?: string;
fields?: FormField[];
button_text?: string;
success_message?: string;
style?: 'default' | 'card' | 'inline';
}
const {
form_id,
title,
subtitle,
fields = [
{ name: 'name', label: 'Your Name', type: 'text', required: true },
{ name: 'email', label: 'Email Address', type: 'email', required: true },
{ name: 'phone', label: 'Phone Number', type: 'phone', required: false },
{ name: 'message', label: 'Message', type: 'textarea', required: false }
],
button_text = 'Submit',
success_message = 'Thank you! We\'ll be in touch soon.',
style = 'card'
} = Astro.props;
const formId = form_id || `form-${Math.random().toString(36).substr(2, 9)}`;
---
<section class={`max-w-2xl mx-auto px-6 py-12 ${style === 'card' ? '' : ''}`}>
<div class={style === 'card' ? 'bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8' : ''}>
{(title || subtitle) && (
<div class="text-center mb-8">
{title && (
<h2 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-2">
{title}
</h2>
)}
{subtitle && (
<p class="text-gray-600 dark:text-gray-400">
{subtitle}
</p>
)}
</div>
)}
<form id={formId} class="space-y-6" data-success-message={success_message}>
{fields.map((field) => (
<div>
<label
for={`${formId}-${field.name}`}
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{field.label}
{field.required && <span class="text-red-500 ml-1">*</span>}
</label>
{field.type === 'textarea' ? (
<textarea
id={`${formId}-${field.name}`}
name={field.name}
required={field.required}
placeholder={field.placeholder}
rows={4}
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent transition-colors"
/>
) : field.type === 'select' && field.options ? (
<select
id={`${formId}-${field.name}`}
name={field.name}
required={field.required}
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent transition-colors"
>
<option value="">Select an option</option>
{field.options.map((option) => (
<option value={option}>{option}</option>
))}
</select>
) : field.type === 'checkbox' ? (
<div class="flex items-center">
<input
type="checkbox"
id={`${formId}-${field.name}`}
name={field.name}
required={field.required}
class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary"
/>
<label for={`${formId}-${field.name}`} class="ml-2 text-sm text-gray-600 dark:text-gray-400">
{field.placeholder || field.label}
</label>
</div>
) : (
<input
type={field.type}
id={`${formId}-${field.name}`}
name={field.name}
required={field.required}
placeholder={field.placeholder}
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent transition-colors"
/>
)}
</div>
))}
<button
type="submit"
class="w-full px-6 py-4 bg-gradient-to-r from-primary to-purple-600 text-white rounded-lg font-semibold text-lg shadow-lg hover:shadow-xl hover:scale-[1.02] transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span class="submit-text">{button_text}</span>
<span class="loading-text hidden">Submitting...</span>
</button>
</form>
<div id={`${formId}-success`} class="hidden text-center py-8">
<div class="w-16 h-16 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<p class="text-lg font-semibold text-gray-900 dark:text-white">{success_message}</p>
</div>
</div>
</section>
<script>
document.querySelectorAll('form[id^="form-"]').forEach(form => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formEl = e.target as HTMLFormElement;
const formId = formEl.id;
const submitBtn = formEl.querySelector('button[type="submit"]') as HTMLButtonElement;
const submitText = submitBtn.querySelector('.submit-text');
const loadingText = submitBtn.querySelector('.loading-text');
// Show loading state
submitBtn.disabled = true;
submitText?.classList.add('hidden');
loadingText?.classList.remove('hidden');
try {
const formData = new FormData(formEl);
const data = Object.fromEntries(formData.entries());
const response = await fetch('/api/lead', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
formEl.classList.add('hidden');
document.getElementById(`${formId}-success`)?.classList.remove('hidden');
}
} catch (err) {
console.error('Form submission error:', err);
} finally {
submitBtn.disabled = false;
submitText?.classList.remove('hidden');
loadingText?.classList.add('hidden');
}
});
});
</script>

View File

@@ -0,0 +1,62 @@
---
interface GalleryImage {
url: string;
alt?: string;
caption?: string;
}
interface Props {
title?: string;
images: GalleryImage[];
columns?: 2 | 3 | 4;
gap?: 'sm' | 'md' | 'lg';
style?: 'grid' | 'masonry';
}
const {
title,
images = [],
columns = 3,
gap = 'md',
style = 'grid'
} = Astro.props;
const colClasses = {
2: 'md:grid-cols-2',
3: 'md:grid-cols-3',
4: 'md:grid-cols-4'
};
const gapClasses = {
sm: 'gap-2',
md: 'gap-4',
lg: 'gap-6'
};
---
<section class="max-w-6xl mx-auto px-6 py-12">
{title && (
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-8 text-center">
{title}
</h2>
)}
<div class={`grid grid-cols-2 ${colClasses[columns]} ${gapClasses[gap]}`}>
{images.map((img, index) => (
<figure class="group relative overflow-hidden rounded-xl">
<img
src={img.url}
alt={img.alt || img.caption || `Gallery image ${index + 1}`}
loading="lazy"
class="w-full h-64 object-cover transition-transform duration-500 group-hover:scale-110"
/>
{img.caption && (
<figcaption class="absolute inset-0 bg-black/60 flex items-end p-4 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<span class="text-white text-sm">{img.caption}</span>
</figcaption>
)}
</figure>
))}
</div>
</section>

View File

@@ -0,0 +1,78 @@
---
interface Props {
title: string;
subtitle?: string;
background_image?: string;
background_gradient?: string;
cta_text?: string;
cta_url?: string;
cta_secondary_text?: string;
cta_secondary_url?: string;
alignment?: 'left' | 'center' | 'right';
}
const {
title,
subtitle,
background_image,
background_gradient = 'from-blue-900 via-purple-900 to-indigo-900',
cta_text,
cta_url,
cta_secondary_text,
cta_secondary_url,
alignment = 'center'
} = Astro.props;
const alignmentClasses = {
left: 'items-start text-left',
center: 'items-center text-center',
right: 'items-end text-right'
};
---
<section
class={`relative min-h-[70vh] flex items-center justify-center py-20 bg-gradient-to-br ${background_gradient}`}
style={background_image ? `background-image: url('${background_image}'); background-size: cover; background-position: center;` : ''}
>
{background_image && <div class="absolute inset-0 bg-black/50"></div>}
<div class={`relative z-10 max-w-5xl mx-auto px-6 flex flex-col gap-6 ${alignmentClasses[alignment]}`}>
<h1 class="text-4xl md:text-6xl lg:text-7xl font-extrabold text-white leading-tight drop-shadow-2xl">
{title}
</h1>
{subtitle && (
<p class="text-xl md:text-2xl text-white/90 max-w-3xl leading-relaxed">
{subtitle}
</p>
)}
{(cta_text || cta_secondary_text) && (
<div class="flex flex-wrap gap-4 mt-6">
{cta_text && cta_url && (
<a
href={cta_url}
class="inline-flex items-center px-8 py-4 bg-white text-gray-900 rounded-xl font-bold text-lg shadow-xl hover:bg-gray-100 hover:scale-105 transition-all duration-300"
>
{cta_text}
<svg class="ml-2 w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</a>
)}
{cta_secondary_text && cta_secondary_url && (
<a
href={cta_secondary_url}
class="inline-flex items-center px-8 py-4 border-2 border-white text-white rounded-xl font-bold text-lg hover:bg-white/10 transition-all duration-300"
>
{cta_secondary_text}
</a>
)}
</div>
)}
</div>
<!-- Decorative elements -->
<div class="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-background to-transparent"></div>
</section>

View File

@@ -0,0 +1,51 @@
---
interface Props {
media_url: string;
media_type?: 'image' | 'video';
caption?: string;
alt?: string;
size?: 'sm' | 'md' | 'lg' | 'full';
rounded?: boolean;
shadow?: boolean;
}
const {
media_url,
media_type = 'image',
caption,
alt,
size = 'lg',
rounded = true,
shadow = true
} = Astro.props;
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-2xl',
lg: 'max-w-4xl',
full: 'max-w-full'
};
---
<figure class={`${sizeClasses[size]} mx-auto my-12 px-6`}>
{media_type === 'video' ? (
<video
src={media_url}
controls
class={`w-full ${rounded ? 'rounded-xl' : ''} ${shadow ? 'shadow-2xl' : ''}`}
/>
) : (
<img
src={media_url}
alt={alt || caption || ''}
loading="lazy"
class={`w-full h-auto ${rounded ? 'rounded-xl' : ''} ${shadow ? 'shadow-2xl' : ''} transition-transform duration-300 hover:scale-[1.02]`}
/>
)}
{caption && (
<figcaption class="mt-4 text-center text-gray-600 dark:text-gray-400 text-sm italic">
{caption}
</figcaption>
)}
</figure>

View File

@@ -0,0 +1,207 @@
---
import type { Post } from '@/types/schema';
interface Props {
title?: string;
subtitle?: string;
posts: Post[];
layout?: 'grid' | 'list' | 'featured';
show_excerpt?: boolean;
show_date?: boolean;
show_category?: boolean;
cta_text?: string;
cta_url?: string;
}
const {
title,
subtitle,
posts = [],
layout = 'grid',
show_excerpt = true,
show_date = true,
show_category = true,
cta_text,
cta_url
} = Astro.props;
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
---
<section class="max-w-6xl mx-auto px-6 py-16">
{(title || subtitle) && (
<div class="text-center mb-12">
{title && (
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{title}
</h2>
)}
{subtitle && (
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{subtitle}
</p>
)}
</div>
)}
{layout === 'featured' && posts.length > 0 ? (
<div class="grid md:grid-cols-2 gap-8">
<!-- Featured post -->
<a href={`/blog/${posts[0].slug}`} class="group block md:col-span-1 md:row-span-2">
<article class="h-full bg-white dark:bg-gray-800 rounded-2xl shadow-xl overflow-hidden hover:shadow-2xl transition-shadow duration-300">
{posts[0].featured_image && (
<div class="relative h-64 md:h-full overflow-hidden">
<img
src={posts[0].featured_image}
alt={posts[0].title}
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent"></div>
<div class="absolute bottom-0 left-0 right-0 p-6 text-white">
{show_category && posts[0].category && (
<span class="inline-block px-3 py-1 bg-primary rounded-full text-sm font-medium mb-3">
{posts[0].category}
</span>
)}
<h3 class="text-2xl font-bold mb-2">{posts[0].title}</h3>
{show_date && posts[0].published_at && (
<time class="text-sm text-gray-300">{formatDate(posts[0].published_at)}</time>
)}
</div>
</div>
)}
</article>
</a>
<!-- Other posts -->
<div class="space-y-6">
{posts.slice(1, 4).map((post) => (
<a href={`/blog/${post.slug}`} class="group block">
<article class="flex gap-4 bg-white dark:bg-gray-800 rounded-xl shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300">
{post.featured_image && (
<img
src={post.featured_image}
alt={post.title}
class="w-32 h-32 object-cover flex-shrink-0"
/>
)}
<div class="flex-1 p-4">
{show_category && post.category && (
<span class="text-xs font-medium text-primary uppercase tracking-wide">
{post.category}
</span>
)}
<h3 class="font-bold text-gray-900 dark:text-white group-hover:text-primary transition-colors mt-1">
{post.title}
</h3>
{show_date && post.published_at && (
<time class="text-sm text-gray-500 dark:text-gray-400 mt-2 block">
{formatDate(post.published_at)}
</time>
)}
</div>
</article>
</a>
))}
</div>
</div>
) : layout === 'list' ? (
<div class="space-y-6">
{posts.map((post) => (
<a href={`/blog/${post.slug}`} class="group block">
<article class="flex gap-6 bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 hover:shadow-lg transition-shadow duration-300">
{post.featured_image && (
<img
src={post.featured_image}
alt={post.title}
class="w-48 h-32 object-cover rounded-lg flex-shrink-0"
/>
)}
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
{show_category && post.category && (
<span class="text-xs font-medium text-primary uppercase tracking-wide">
{post.category}
</span>
)}
{show_date && post.published_at && (
<time class="text-sm text-gray-500 dark:text-gray-400">
{formatDate(post.published_at)}
</time>
)}
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white group-hover:text-primary transition-colors mb-2">
{post.title}
</h3>
{show_excerpt && post.excerpt && (
<p class="text-gray-600 dark:text-gray-400 line-clamp-2">
{post.excerpt}
</p>
)}
</div>
</article>
</a>
))}
</div>
) : (
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{posts.map((post) => (
<a href={`/blog/${post.slug}`} class="group block">
<article class="bg-white dark:bg-gray-800 rounded-xl shadow-md overflow-hidden hover:shadow-xl transition-all duration-300 h-full flex flex-col">
{post.featured_image && (
<div class="relative h-48 overflow-hidden">
<img
src={post.featured_image}
alt={post.title}
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
</div>
)}
<div class="p-6 flex-1 flex flex-col">
<div class="flex items-center gap-3 mb-3">
{show_category && post.category && (
<span class="text-xs font-medium text-primary uppercase tracking-wide">
{post.category}
</span>
)}
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-white group-hover:text-primary transition-colors mb-2">
{post.title}
</h3>
{show_excerpt && post.excerpt && (
<p class="text-gray-600 dark:text-gray-400 text-sm line-clamp-3 mb-4 flex-1">
{post.excerpt}
</p>
)}
{show_date && post.published_at && (
<time class="text-sm text-gray-500 dark:text-gray-400 mt-auto">
{formatDate(post.published_at)}
</time>
)}
</div>
</article>
</a>
))}
</div>
)}
{cta_text && cta_url && (
<div class="text-center mt-12">
<a
href={cta_url}
class="inline-flex items-center px-6 py-3 bg-primary text-white rounded-lg font-semibold hover:bg-primary/90 transition-colors"
>
{cta_text}
<svg class="ml-2 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</a>
</div>
)}
</section>

View File

@@ -0,0 +1,86 @@
---
interface Props {
quote: string;
author?: string;
author_title?: string;
author_image?: string;
style?: 'simple' | 'card' | 'large';
}
const {
quote,
author,
author_title,
author_image,
style = 'card'
} = Astro.props;
---
{style === 'large' ? (
<section class="bg-gradient-to-br from-gray-900 to-gray-800 py-20 my-12">
<div class="max-w-4xl mx-auto px-6 text-center">
<svg class="w-12 h-12 text-primary/50 mx-auto mb-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z"/>
</svg>
<blockquote class="text-2xl md:text-4xl font-medium text-white leading-relaxed mb-8">
"{quote}"
</blockquote>
{author && (
<div class="flex items-center justify-center gap-4">
{author_image && (
<img
src={author_image}
alt={author}
class="w-16 h-16 rounded-full object-cover border-2 border-primary"
/>
)}
<div class="text-left">
<div class="text-white font-semibold text-lg">{author}</div>
{author_title && (
<div class="text-gray-400">{author_title}</div>
)}
</div>
</div>
)}
</div>
</section>
) : style === 'card' ? (
<div class="max-w-3xl mx-auto my-12 px-6">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 border-l-4 border-primary">
<blockquote class="text-xl text-gray-700 dark:text-gray-300 leading-relaxed italic mb-6">
"{quote}"
</blockquote>
{author && (
<div class="flex items-center gap-4">
{author_image && (
<img
src={author_image}
alt={author}
class="w-12 h-12 rounded-full object-cover"
/>
)}
<div>
<div class="font-semibold text-gray-900 dark:text-white">{author}</div>
{author_title && (
<div class="text-sm text-gray-500 dark:text-gray-400">{author_title}</div>
)}
</div>
</div>
)}
</div>
</div>
) : (
<blockquote class="max-w-3xl mx-auto my-12 px-6 border-l-4 border-primary pl-6">
<p class="text-2xl text-gray-700 dark:text-gray-300 italic leading-relaxed">
"{quote}"
</p>
{author && (
<footer class="mt-4 text-gray-600 dark:text-gray-400">
— {author}{author_title && `, ${author_title}`}
</footer>
)}
</blockquote>
)}

View File

@@ -0,0 +1,35 @@
---
interface Props {
content: string;
max_width?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
}
const { content, max_width = 'lg' } = Astro.props;
const widthClasses = {
sm: 'max-w-2xl',
md: 'max-w-3xl',
lg: 'max-w-4xl',
xl: 'max-w-5xl',
full: 'max-w-full'
};
---
<section class={`${widthClasses[max_width]} mx-auto px-6 py-12`}>
<div
class="prose prose-lg prose-gray dark:prose-invert max-w-none
prose-headings:font-bold prose-headings:text-gray-900 dark:prose-headings:text-white
prose-h2:text-3xl prose-h2:mt-12 prose-h2:mb-6
prose-h3:text-2xl prose-h3:mt-8 prose-h3:mb-4
prose-p:leading-relaxed prose-p:text-gray-700 dark:prose-p:text-gray-300
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
prose-strong:text-gray-900 dark:prose-strong:text-white
prose-ul:space-y-2 prose-ol:space-y-2
prose-li:text-gray-700 dark:prose-li:text-gray-300
prose-blockquote:border-l-4 prose-blockquote:border-primary prose-blockquote:bg-gray-50 dark:prose-blockquote:bg-gray-800 prose-blockquote:py-4 prose-blockquote:px-6 prose-blockquote:rounded-r-lg
prose-table:border prose-table:rounded-lg prose-table:overflow-hidden
prose-th:bg-gray-100 dark:prose-th:bg-gray-800 prose-th:px-4 prose-th:py-3
prose-td:px-4 prose-td:py-3 prose-td:border-t"
set:html={content}
/>
</section>

View File

@@ -0,0 +1,86 @@
---
interface Step {
title: string;
description: string;
icon?: string;
}
interface Props {
title?: string;
subtitle?: string;
steps: Step[];
layout?: 'vertical' | 'horizontal';
}
const {
title,
subtitle,
steps = [],
layout = 'vertical'
} = Astro.props;
---
<section class="max-w-5xl mx-auto px-6 py-16">
{(title || subtitle) && (
<div class="text-center mb-12">
{title && (
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{title}
</h2>
)}
{subtitle && (
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{subtitle}
</p>
)}
</div>
)}
{layout === 'horizontal' ? (
<div class="grid md:grid-cols-3 gap-8">
{steps.map((step, index) => (
<div class="relative text-center group">
<div class="w-16 h-16 bg-gradient-to-br from-primary to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg group-hover:scale-110 transition-transform duration-300">
<span class="text-2xl font-bold text-white">{index + 1}</span>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
{step.title}
</h3>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
{step.description}
</p>
{index < steps.length - 1 && (
<div class="hidden md:block absolute top-8 left-[60%] w-[80%] h-0.5 bg-gradient-to-r from-primary/50 to-transparent"></div>
)}
</div>
))}
</div>
) : (
<div class="space-y-8">
{steps.map((step, index) => (
<div class="flex gap-6 group">
<div class="flex flex-col items-center">
<div class="w-12 h-12 bg-gradient-to-br from-primary to-purple-600 rounded-xl flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform duration-300">
<span class="text-xl font-bold text-white">{index + 1}</span>
</div>
{index < steps.length - 1 && (
<div class="w-0.5 flex-1 bg-gradient-to-b from-primary/50 to-transparent mt-4"></div>
)}
</div>
<div class="flex-1 pb-8">
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">
{step.title}
</h3>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
{step.description}
</p>
</div>
</div>
))}
</div>
)}
</section>

View File

View File

View File

View File

View File

View File

View File

View File

View File

View File

@@ -0,0 +1,133 @@
import React from 'react';
import { useNode } from '@craftjs/core';
import { Slider } from "@/components/ui/slider";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
export const Text = ({ text, fontSize = 16, textAlign = 'left' }: { text: string; fontSize?: number; textAlign?: string }) => {
const { connectors: { connect, drag }, actions: { setProp }, hasSelectedNode } = useNode((node) => ({
hasSelectedNode: node.events.selected,
}));
return (
<div
ref={(ref) => { connect(drag(ref as HTMLElement)); }}
onClick={() => !hasSelectedNode && setProp((props: any) => props.hasSelectedNode = true)}
style={{ fontSize: `${fontSize}px`, textAlign: textAlign as any }}
className="p-2 hover:outline hover:outline-1 hover:outline-blue-500 relative group"
>
{hasSelectedNode && (
<div className="absolute -top-8 left-0 bg-blue-500 text-white text-xs px-2 py-1 rounded">Text</div>
)}
<p className="w-full">{text}</p>
</div>
);
};
export const TextSettings = () => {
const { actions: { setProp }, fontSize, textAlign } = useNode((node) => ({
fontSize: node.data.props.fontSize,
textAlign: node.data.props.textAlign,
}));
return (
<div className="space-y-4">
<div className="space-y-2">
<Label>Font Size</Label>
<Slider
defaultValue={fontSize || 16}
max={100}
step={1}
onChange={(e) => {
const val = Number(e.target.value);
setProp((props: any) => props.fontSize = val);
}}
/>
</div>
<div className="space-y-2">
<Label>Alignment</Label>
<div className="flex gap-2">
{['left', 'center', 'right', 'justify'].map((align) => (
<button
key={align}
className={`px-2 py-1 border rounded ${textAlign === align ? 'bg-primary text-primary-foreground' : ''}`}
onClick={() => setProp((props: any) => props.textAlign = align)}
>
{align.charAt(0).toUpperCase() + align.slice(1)}
</button>
))}
</div>
</div>
</div>
);
};
Text.craft = {
props: {
text: 'Hi there',
fontSize: 16,
textAlign: 'left'
},
related: {
settings: TextSettings,
},
};
export const Container = ({ background = "#ffffff", padding = 20, children }: { background?: string; padding?: number; children?: React.ReactNode }) => {
const { connectors: { connect, drag } } = useNode();
return (
<div
ref={(ref) => { connect(drag(ref as HTMLElement)); }}
style={{ background, padding: `${padding}px` }}
className="border border-dashed border-gray-200 min-h-[100px]"
>
{children}
</div>
);
};
export const ContainerSettings = () => {
const { background, padding, actions: { setProp } } = useNode((node) => ({
background: node.data.props.background,
padding: node.data.props.padding,
}));
return (
<div className="space-y-4">
<div className="space-y-2">
<Label>Background Color</Label>
<Input
type="color"
value={background}
onChange={(e) => {
const val = e.target.value;
setProp((props: any) => props.background = val);
}}
/>
</div>
<div className="space-y-2">
<Label>Padding</Label>
<Slider
defaultValue={padding}
max={100}
step={1}
onChange={(e) => {
const val = Number(e.target.value);
setProp((props: any) => props.padding = val);
}}
/>
</div>
</div>
);
};
Container.craft = {
props: {
background: '#ffffff',
padding: 20
},
related: {
settings: ContainerSettings,
},
};

View File

@@ -0,0 +1,65 @@
import React from 'react';
import { Editor, Frame, Element } from '@craftjs/core';
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Undo, Redo, Smartphone, Monitor } from 'lucide-react';
import { Text, Container } from './UserBlocks';
import { Toolbox, SettingsPanel } from './editor/Panels';
const ViewportHeader = () => {
return (
<div className="flex items-center justify-between p-2 border-b bg-card/30">
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8"><Smartphone className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon" className="h-8 w-8 bg-muted"><Monitor className="h-4 w-4" /></Button>
</div>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8"><Undo className="h-4 w-4" /></Button>
<Button variant="ghost" size="icon" className="h-8 w-8"><Redo className="h-4 w-4" /></Button>
</div>
</div>
)
}
const VisualBlockEditor = () => {
return (
<div className="h-[calc(100vh-140px)] w-full">
<Editor resolver={{ Text, Container }}>
<div className="grid grid-cols-12 gap-6 h-full">
{/* Left: Toolbox */}
<div className="col-span-2">
<Toolbox />
</div>
{/* Center: Canvas */}
<div className="col-span-7 flex flex-col h-full">
<Card className="flex-1 flex flex-col bg-background/50 overflow-hidden border-border/50">
<ViewportHeader />
<div className="flex-1 p-8 bg-black/5 dark:bg-black/20 overflow-y-auto">
<div className="bg-background shadow-lg min-h-[800px] w-full max-w-3xl mx-auto rounded-md overflow-hidden">
<Frame>
<Element is={Container} padding={40} background="#ffffff05" canvas>
<Text text="Welcome to the Visual Editor" />
<Element is={Container} padding={20} background="#00000020" canvas>
<Text text="Drag blocks from the left to build your page." />
</Element>
</Element>
</Frame>
</div>
</div>
</Card>
</div>
{/* Right: Settings */}
<div className="col-span-3">
<SettingsPanel />
</div>
</div>
</Editor>
</div>
);
};
export default VisualBlockEditor;

View File

@@ -0,0 +1,90 @@
import React from 'react';
import { useEditor } from '@craftjs/core';
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Type, Box, Image, Layers } from 'lucide-react';
import { Text, Container } from './UserBlocks';
export const Toolbox = () => {
const { connectors } = useEditor();
return (
<Card className="p-4 flex flex-col gap-3 bg-card/50 backdrop-blur">
<h3 className="text-xs font-semibold uppercase text-muted-foreground tracking-wider mb-1">Blocks</h3>
<Button
variant="outline"
className="justify-start gap-2 cursor-move"
ref={(ref) => connectors.create(ref!, <Text text="New Text Block" />)}
>
<Type className="h-4 w-4" />
Text
</Button>
<Button
variant="outline"
className="justify-start gap-2 cursor-move"
ref={(ref) => connectors.create(ref!, <Container padding={20} />)}
>
<Box className="h-4 w-4" />
Container
</Button>
<Button variant="outline" className="justify-start gap-2" disabled>
<Image className="h-4 w-4" />
Image (Pro)
</Button>
<Button variant="outline" className="justify-start gap-2" disabled>
<Layers className="h-4 w-4" />
Layout (Pro)
</Button>
</Card>
);
};
export const SettingsPanel = () => {
// Fix: Correctly access node state using type assertion or checking if exists
const { selected, actions } = useEditor((state) => {
const [currentNodeId] = state.events.selected;
let selected;
if (currentNodeId) {
const node = state.nodes[currentNodeId];
selected = {
id: currentNodeId,
name: node.data.displayName,
settings: node.related && node.related.settings,
isDeletable: (node.data as any).isDeletable !== false, // Use type assertion or default true
props: node.data.props
};
}
return { selected };
});
if (!selected) {
return (
<Card className="p-6 bg-card/50 backdrop-blur flex items-center justify-center text-center">
<div className="text-muted-foreground text-sm">
Select an element to edit properties
</div>
</Card>
)
}
return (
<Card className="bg-card/50 backdrop-blur">
<div className="p-3 border-b flex justify-between items-center">
<h3 className="font-semibold text-sm">{selected.name}</h3>
{selected.isDeletable && (
<Button variant="destructive" size="sm" onClick={() => actions.delete(selected.id)}>
Delete
</Button>
)}
</div>
{selected.settings && React.createElement(selected.settings, { ...selected.props, setProp: (cb: any) => actions.setProp(selected.id, cb) })}
</Card>
)
}

View File

@@ -0,0 +1,105 @@
import React from 'react';
import { useNode } from '@craftjs/core';
import { Slider } from "@/components/ui/slider";
import { Label } from "@/components/ui/label";
// --- Types ---
interface TextProps {
text?: string;
}
interface ContainerProps {
children?: React.ReactNode;
padding?: number;
background?: string;
}
// --- Components ---
export const Text = ({ text = "Edit me" }: TextProps) => {
const { connectors: { connect, drag }, actions: { setProp }, selected } = useNode((state) => ({
selected: state.events.selected,
}));
// Fix: Explicitly type the 'props' argument in setProp callback
const handleInput = (e: React.FormEvent<HTMLDivElement>) => {
const newText = e.currentTarget.textContent || "";
setProp((props: any) => props.text = newText);
};
const handleClick = () => {
if (!selected) {
setProp((props: any) => props.text = text);
}
}
return (
<div
ref={(ref) => connect(drag(ref!))}
onClick={handleClick}
contentEditable={selected}
suppressContentEditableWarning
onBlur={handleInput}
className={`p-2 transition-all outline-none ${selected ? "ring-2 ring-primary ring-offset-2 rounded" : "hover:bg-primary/5"}`}
>
{text}
</div>
);
};
// Craft config for Text
(Text as any).craft = {
displayName: "Text Block",
props: {
text: "Start typing..."
},
related: {
settings: () => (
<div className="p-4">
<Label>Typography Settings</Label>
<p className="text-xs text-muted-foreground mt-2">Edit text directly on the canvas.</p>
</div>
)
}
};
export const Container = ({ children, padding = 20, background = 'transparent' }: ContainerProps) => {
const { connectors: { connect, drag }, selected } = useNode((state) => ({
selected: state.events.selected,
}));
return (
<div
ref={(ref) => connect(drag(ref!))}
className={`border border-dashed transition-all ${selected ? "border-primary" : "border-border/50 hover:border-primary/50"}`}
style={{ padding: `${padding}px`, backgroundColor: background }}
>
{children}
</div>
);
};
// Craft config for Container
(Container as any).craft = {
displayName: "Section Container",
props: {
padding: 20,
background: 'transparent'
},
related: {
settings: ({ padding, setProp }: any) => (
<div className="p-4 space-y-4">
<div>
<Label>Padding ({padding}px)</Label>
<Slider
value={[padding]}
max={100}
step={5}
onValueChange={(val: number[]) => setProp((props: any) => props.padding = val[0])}
/>
</div>
</div>
)
}
};

View File

View File

@@ -0,0 +1,211 @@
/**
* Universal Collection Manager
* Reusable component for all collection pages
*/
'use client';
import { useState, useEffect } from 'react';
import { Button } from "@/components/ui/button";
import { Edit, Trash2, Plus, ExternalLink } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
interface CollectionManagerProps {
collection: string;
title: string;
description: string;
icon: string;
displayField: string;
}
export default function CollectionManager({
collection,
title,
description,
icon,
displayField,
}: CollectionManagerProps) {
const [items, setItems] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchItems();
}, [collection]);
const fetchItems = async () => {
try {
setLoading(true);
const response = await fetch(
`https://spark.jumpstartscaling.com/items/${collection}?limit=100`,
{
headers: {
Authorization: `Bearer ${import.meta.env.PUBLIC_DIRECTUS_TOKEN}`,
},
}
);
if (!response.ok) throw new Error(`Failed to fetch ${collection}`);
const data = await response.json();
setItems(data.data || []);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
};
const handleBulkExport = () => {
const jsonStr = JSON.stringify(items, null, 2);
const blob = new Blob([jsonStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${collection}_export.json`;
a.click();
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="spark-data animate-pulse">Loading {title}...</div>
</div>
);
}
if (error) {
return (
<div className="spark-card p-6 border-red-500">
<div className="text-red-400">Error: {error}</div>
<button onClick={fetchItems} className="spark-btn-secondary text-sm mt-4">
Retry
</button>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-between items-center">
<div>
<h1 className="spark-heading text-3xl flex items-center gap-3">
<span>{icon}</span>
{title}
</h1>
<p className="text-silver mt-1">{description}</p>
</div>
<div className="flex gap-3">
<button className="spark-btn-secondary text-sm" onClick={handleBulkExport}>
📤 Export
</button>
<button className="spark-btn-secondary text-sm">
📥 Import
</button>
<button className="spark-btn-primary text-sm">
New {title.slice(0, -1)}
</button>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
<div className="spark-card p-6">
<div className="spark-label mb-2">Total Items</div>
<div className="spark-data text-3xl">{items.length}</div>
</div>
<div className="spark-card p-6">
<div className="spark-label mb-2">This Week</div>
<div className="spark-data text-3xl">0</div>
</div>
<div className="spark-card p-6">
<div className="spark-label mb-2">Usage Count</div>
<div className="spark-data text-3xl"></div>
</div>
<div className="spark-card p-6">
<div className="spark-label mb-2">Status</div>
<div className="text-green-400 text-sm"> Active</div>
</div>
</div>
{/* Table */}
<div className="spark-card overflow-hidden">
<table className="spark-table">
<thead>
<tr>
<th className="w-12">
<input type="checkbox" className="w-4 h-4" />
</th>
<th>ID</th>
<th>{displayField}</th>
<th>Created</th>
<th className="text-right">Actions</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id}>
<td>
<input type="checkbox" className="w-4 h-4" />
</td>
<td className="spark-data text-sm">{item.id}</td>
<td className="text-white font-medium">
{item[displayField] || 'Untitled'}
</td>
<td className="text-silver text-sm">
{item.date_created
? new Date(item.date_created).toLocaleDateString()
: '—'}
</td>
<td className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="spark-btn-ghost text-xs px-2 py-1">
Actions
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
{/* Preview Action for Posts/Pages */}
{['posts', 'pages', 'generated_articles'].includes(collection) && (
<DropdownMenuItem
onClick={() => {
// Fallback site ID since directus schema might vary on how it stores site ref
const siteId = (item as any).site || (item as any).site_id || 'default';
const url = `https://launch.jumpstartscaling.com/site/${siteId}/preview/${item.id}`;
window.open(url, '_blank');
}}
>
<ExternalLink className="mr-2 h-4 w-4" /> Preview
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => handleEdit(item)}>
<Edit className="mr-2 h-4 w-4" /> Edit
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
))}
</tbody>
</table>
{items.length === 0 && (
<div className="p-12 text-center">
<p className="text-silver/50">No items found. Create your first one!</p>
</div>
)}
</div>
</div>
);
}

View File

View File

@@ -0,0 +1,221 @@
/**
* Bulk Grid Component
* High-performance table view with TanStack Table
*/
'use client';
import { useMemo, useState } from 'react';
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getFilteredRowModel,
flexRender,
ColumnDef,
SortingState,
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
interface Article {
id: string;
title: string;
status: string;
seo_score?: number;
geo_city?: string;
geo_state?: string;
date_created?: string;
}
interface BulkGridProps {
articles: Article[];
onBulkAction: (action: string, articleIds: string[]) => void;
}
export default function BulkGrid({ articles, onBulkAction }: BulkGridProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const [rowSelection, setRowSelection] = useState({});
const columns = useMemo<ColumnDef<Article>[]>(
() => [
{
id: 'select',
header: ({ table }) => (
<input
type="checkbox"
checked={table.getIsAllRowsSelected()}
onChange={table.getToggleAllRowsSelectedHandler()}
className="w-4 h-4 rounded border-edge-subtle bg-graphite"
/>
),
cell: ({ row }) => (
<input
type="checkbox"
checked={row.getIsSelected()}
onChange={row.getToggleSelectedHandler()}
className="w-4 h-4 rounded border-edge-subtle bg-graphite"
/>
),
},
{
accessorKey: 'title',
header: 'Title',
cell: (info) => (
<div className="text-white font-medium">{info.getValue() as string}</div>
),
},
{
accessorKey: 'status',
header: 'Status',
cell: (info) => {
const status = info.getValue() as string;
const statusColors = {
queued: 'border-slate-500 text-slate-400',
generating: 'border-electric-400 text-electric-400',
review: 'border-yellow-500 text-yellow-400',
approved: 'border-green-500 text-green-400',
published: 'border-edge-gold text-gold-300',
};
return (
<span className={`spark-status ${statusColors[status as keyof typeof statusColors]}`}>
{status}
</span>
);
},
},
{
accessorKey: 'geo_city',
header: 'Location',
cell: (info) => {
const city = info.getValue() as string;
const state = info.row.original.geo_state;
return city ? (
<span className="text-silver">
{city}{state ? `, ${state}` : ''}
</span>
) : <span className="text-silver/50"></span>;
},
},
{
accessorKey: 'seo_score',
header: 'SEO',
cell: (info) => {
const score = info.getValue() as number;
return score ? (
<span className="spark-data">{score}/100</span>
) : <span className="text-silver/50"></span>;
},
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<div className="flex gap-2">
<button className="spark-btn-ghost text-xs px-2 py-1">
Edit
</button>
<button className="spark-btn-ghost text-xs px-2 py-1">
Preview
</button>
</div>
),
},
],
[]
);
const table = useReactTable({
data: articles,
columns,
state: {
sorting,
rowSelection,
},
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
});
const selectedCount = Object.keys(rowSelection).length;
return (
<div className="space-y-4">
{/* Bulk Actions Bar */}
{selectedCount > 0 && (
<div className="spark-card p-4 flex items-center justify-between">
<span className="spark-data">{selectedCount} selected</span>
<div className="flex gap-2">
<button
onClick={() => onBulkAction('approve', Object.keys(rowSelection))}
className="spark-btn-secondary text-sm"
>
Approve
</button>
<button
onClick={() => onBulkAction('publish', Object.keys(rowSelection))}
className="spark-btn-primary text-sm"
>
Publish
</button>
<button
onClick={() => onBulkAction('delete', Object.keys(rowSelection))}
className="spark-btn-ghost text-red-400 text-sm"
>
Delete
</button>
</div>
</div>
)}
{/* Table */}
<div className="spark-card overflow-hidden">
<div className="overflow-x-auto">
<table className="spark-table">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className="text-left p-4">
{header.isPlaceholder ? null : (
<div
className={
header.column.getCanSort()
? 'cursor-pointer select-none flex items-center gap-2'
: ''
}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{{
asc: ' 🔼',
desc: ' 🔽',
}[header.column.getIsSorted() as string] ?? null}
</div>
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="p-4 border-b border-edge-subtle">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,134 @@
/**
* Kanban Board Component
* Drag-and-drop workflow for article production
*/
'use client';
import { useState } from 'react';
import { DndContext, DragEndEvent, DragOverlay, DragStartEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { motion } from 'framer-motion';
import KanbanCard from './KanbanCard';
type ArticleStatus = 'queued' | 'generating' | 'review' | 'approved' | 'published';
interface Article {
id: string;
title: string;
status: ArticleStatus;
geo_city?: string;
seo_score?: number;
}
interface KanbanBoardProps {
articles: Article[];
onStatusChange: (articleId: string, newStatus: ArticleStatus) => void;
}
const columns: { id: ArticleStatus; title: string; color: string }[] = [
{ id: 'queued', title: 'Queued', color: 'slate' },
{ id: 'generating', title: 'Generating', color: 'electric' },
{ id: 'review', title: 'Review', color: 'yellow' },
{ id: 'approved', title: 'Approved', color: 'green' },
{ id: 'published', title: 'Published', color: 'gold' },
];
export default function KanbanBoard({ articles, onStatusChange }: KanbanBoardProps) {
const [activeId, setActiveId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over) return;
const articleId = active.id as string;
const newStatus = over.id as ArticleStatus;
onStatusChange(articleId, newStatus);
setActiveId(null);
};
const getColumnArticles = (status: ArticleStatus) => {
return articles.filter(article => article.status === status);
};
const activeArticle = articles.find(a => a.id === activeId);
return (
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="grid grid-cols-5 gap-4 h-[calc(100vh-200px)]">
{columns.map((column) => {
const columnArticles = getColumnArticles(column.id);
return (
<div
key={column.id}
className="flex flex-col bg-void/50 border-r border-edge-subtle last:border-r-0 px-3"
>
{/* Column Header */}
<div className="sticky top-0 bg-void/90 backdrop-blur-sm py-4 mb-4 border-b border-edge-subtle">
<div className="flex items-center justify-between">
<h3 className="spark-label text-silver">{column.title}</h3>
<span className="spark-data text-sm">{columnArticles.length}</span>
</div>
</div>
{/* Droppable Zone */}
<SortableContext
id={column.id}
items={columnArticles.map(a => a.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex-1 space-y-3 overflow-y-auto pb-4">
{columnArticles.map((article) => (
<KanbanCard
key={article.id}
article={article}
columnColor={column.color}
/>
))}
{columnArticles.length === 0 && (
<div className="flex items-center justify-center h-32 border-2 border-dashed border-edge-subtle rounded-lg">
<p className="text-silver/50 text-sm">Drop here</p>
</div>
)}
</div>
</SortableContext>
</div>
);
})}
</div>
{/* Drag Overlay */}
<DragOverlay>
{activeArticle && (
<motion.div
initial={{ scale: 1 }}
animate={{ scale: 1.05 }}
className="opacity-80"
>
<KanbanCard article={activeArticle} columnColor="gold" />
</motion.div>
)}
</DragOverlay>
</DndContext>
);
}

View File

@@ -0,0 +1,90 @@
/**
* Kanban Card Component
* Individual article card in Kanban view
*/
'use client';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
interface Article {
id: string;
title: string;
status: string;
geo_city?: string;
seo_score?: number;
}
interface KanbanCardProps {
article: Article;
columnColor: string;
}
export default function KanbanCard({ article, columnColor }: KanbanCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: article.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const getBorderColor = () => {
switch (columnColor) {
case 'electric': return 'border-electric-400';
case 'yellow': return 'border-yellow-500';
case 'green': return 'border-green-500';
case 'gold': return 'border-edge-gold';
default: return 'border-edge-normal';
}
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}
className={`
group spark-card spark-card-hover p-4 cursor-grab active:cursor-grabbing
${getBorderColor()}
transition-all duration-200
`}
>
{/* Title */}
<h4 className="text-white font-medium text-sm mb-2 line-clamp-2">
{article.title}
</h4>
{/* Metadata */}
<div className="flex items-center justify-between text-xs">
{article.geo_city && (
<span className="text-silver">📍 {article.geo_city}</span>
)}
{article.seo_score !== undefined && (
<span className="spark-data">
{article.seo_score}/100
</span>
)}
</div>
{/* Drag Handle Visual */}
<div className="mt-3 pt-3 border-t border-edge-subtle flex justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex gap-1">
<div className="w-1 h-1 rounded-full bg-silver/30"></div>
<div className="w-1 h-1 rounded-full bg-silver/30"></div>
<div className="w-1 h-1 rounded-full bg-silver/30"></div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,372 @@
/**
* Module Flow Builder Component
*
* Visual node-based editor for building content recipes.
* Users drag and connect module blocks to define article structure.
*/
import React, { useState, useCallback } from 'react';
interface ModuleNode {
id: string;
type: string;
label: string;
icon: string;
color: string;
x: number;
y: number;
}
interface Connection {
from: string;
to: string;
}
interface ModuleFlowProps {
onRecipeChange?: (recipe: string[]) => void;
}
const MODULE_TYPES = [
{ type: 'intro', label: 'Intro/Hook', icon: '🎯', color: '#8b5cf6' },
{ type: 'definition', label: 'Definition', icon: '📖', color: '#3b82f6' },
{ type: 'benefits', label: 'Benefits', icon: '✨', color: '#10b981' },
{ type: 'howto', label: 'How-To Steps', icon: '📝', color: '#f59e0b' },
{ type: 'comparison', label: 'Comparison', icon: '⚖️', color: '#ef4444' },
{ type: 'faq', label: 'FAQ', icon: '❓', color: '#06b6d4' },
{ type: 'conclusion', label: 'Conclusion/CTA', icon: '🚀', color: '#ec4899' },
];
export const ModuleFlow: React.FC<ModuleFlowProps> = ({ onRecipeChange }) => {
const [nodes, setNodes] = useState<ModuleNode[]>([]);
const [connections, setConnections] = useState<Connection[]>([]);
const [dragging, setDragging] = useState<string | null>(null);
const [connecting, setConnecting] = useState<string | null>(null);
const addNode = useCallback((type: typeof MODULE_TYPES[0]) => {
const newNode: ModuleNode = {
id: `node-${Date.now()}`,
type: type.type,
label: type.label,
icon: type.icon,
color: type.color,
x: 100 + (nodes.length * 50) % 300,
y: 100 + Math.floor(nodes.length / 3) * 100
};
const updatedNodes = [...nodes, newNode];
setNodes(updatedNodes);
updateRecipe(updatedNodes, connections);
}, [nodes, connections, onRecipeChange]);
const removeNode = useCallback((id: string) => {
const updatedNodes = nodes.filter(n => n.id !== id);
const updatedConnections = connections.filter(c => c.from !== id && c.to !== id);
setNodes(updatedNodes);
setConnections(updatedConnections);
updateRecipe(updatedNodes, updatedConnections);
}, [nodes, connections]);
const handleMouseDown = (id: string, e: React.MouseEvent) => {
if (e.shiftKey) {
setConnecting(id);
} else {
setDragging(id);
}
};
const handleMouseMove = useCallback((e: React.MouseEvent<SVGSVGElement>) => {
if (!dragging) return;
const svg = e.currentTarget;
const rect = svg.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
setNodes(nodes.map(n =>
n.id === dragging ? { ...n, x, y } : n
));
}, [dragging, nodes]);
const handleMouseUp = (targetId?: string) => {
if (connecting && targetId && connecting !== targetId) {
// Don't add duplicate connections
const exists = connections.some(c => c.from === connecting && c.to === targetId);
if (!exists) {
const updatedConnections = [...connections, { from: connecting, to: targetId }];
setConnections(updatedConnections);
updateRecipe(nodes, updatedConnections);
}
}
setDragging(null);
setConnecting(null);
};
const updateRecipe = (currentNodes: ModuleNode[], currentConnections: Connection[]) => {
// Build recipe from connected flow
// Start from nodes that have no incoming connections
const hasIncoming = new Set(currentConnections.map(c => c.to));
const startNodes = currentNodes.filter(n => !hasIncoming.has(n.id));
const visited = new Set<string>();
const recipe: string[] = [];
const traverse = (nodeId: string) => {
if (visited.has(nodeId)) return;
visited.add(nodeId);
const node = currentNodes.find(n => n.id === nodeId);
if (node) {
recipe.push(node.type);
currentConnections
.filter(c => c.from === nodeId)
.forEach(c => traverse(c.to));
}
};
startNodes.forEach(n => traverse(n.id));
// Add any unconnected nodes
currentNodes.forEach(n => {
if (!visited.has(n.id)) {
recipe.push(n.type);
}
});
onRecipeChange?.(recipe);
};
return (
<div className="module-flow-container">
{/* Palette */}
<div className="module-palette">
<h4>📦 Content Modules</h4>
<p className="palette-hint">Click to add, Shift+Drag to connect</p>
<div className="palette-items">
{MODULE_TYPES.map(type => (
<button
key={type.type}
className="palette-item"
style={{ borderColor: type.color }}
onClick={() => addNode(type)}
>
<span className="item-icon">{type.icon}</span>
<span className="item-label">{type.label}</span>
</button>
))}
</div>
</div>
{/* Canvas */}
<div className="flow-canvas-container">
<svg
className="flow-canvas"
onMouseMove={handleMouseMove}
onMouseUp={() => handleMouseUp()}
onMouseLeave={() => handleMouseUp()}
>
{/* Connection lines */}
{connections.map((conn, i) => {
const from = nodes.find(n => n.id === conn.from);
const to = nodes.find(n => n.id === conn.to);
if (!from || !to) return null;
return (
<line
key={i}
x1={from.x + 60}
y1={from.y + 30}
x2={to.x}
y2={to.y + 30}
stroke="#3b82f6"
strokeWidth={2}
markerEnd="url(#arrowhead)"
/>
);
})}
{/* Arrow marker */}
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon points="0 0, 10 3.5, 0 7" fill="#3b82f6" />
</marker>
</defs>
{/* Nodes */}
{nodes.map(node => (
<g
key={node.id}
transform={`translate(${node.x}, ${node.y})`}
onMouseDown={(e) => handleMouseDown(node.id, e)}
onMouseUp={() => handleMouseUp(node.id)}
style={{ cursor: 'grab' }}
>
<rect
width={120}
height={60}
rx={8}
fill="#1a1a2e"
stroke={node.color}
strokeWidth={2}
/>
<text x={15} y={25} fontSize={18}>{node.icon}</text>
<text x={40} y={28} fill="#fff" fontSize={12}>{node.label}</text>
<text
x={108}
y={18}
fill="#888"
fontSize={14}
onClick={(e) => { e.stopPropagation(); removeNode(node.id); }}
style={{ cursor: 'pointer' }}
>×</text>
<text x={15} y={48} fill="#666" fontSize={10}>
{node.type}
</text>
</g>
))}
{nodes.length === 0 && (
<text x="50%" y="50%" textAnchor="middle" fill="#666">
Click modules from the palette to add them here
</text>
)}
</svg>
</div>
{/* Recipe Preview */}
<div className="recipe-preview">
<h4>📜 Current Recipe</h4>
<div className="recipe-items">
{nodes.length === 0 ? (
<span className="empty">No modules added</span>
) : (
nodes.map((n, i) => (
<span key={n.id} className="recipe-item" style={{ background: n.color }}>
{n.icon} {n.type}
{i < nodes.length - 1 && <span className="arrow"></span>}
</span>
))
)}
</div>
</div>
<style>{`
.module-flow-container {
display: grid;
grid-template-columns: 200px 1fr;
grid-template-rows: 1fr auto;
gap: 16px;
height: 500px;
}
.module-palette {
background: rgba(0,0,0,0.3);
border-radius: 12px;
padding: 16px;
grid-row: 1 / 3;
}
.module-palette h4 {
color: #fff;
margin-bottom: 8px;
}
.palette-hint {
font-size: 0.75rem;
color: #666;
margin-bottom: 16px;
}
.palette-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.palette-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
color: #e0e0e0;
cursor: pointer;
transition: all 0.2s;
text-align: left;
}
.palette-item:hover {
background: rgba(255,255,255,0.08);
transform: translateX(4px);
}
.item-icon {
font-size: 1.2rem;
}
.item-label {
font-size: 0.85rem;
}
.flow-canvas-container {
background: rgba(0,0,0,0.3);
border-radius: 12px;
overflow: hidden;
}
.flow-canvas {
width: 100%;
height: 100%;
min-height: 400px;
}
.recipe-preview {
background: rgba(0,0,0,0.3);
border-radius: 12px;
padding: 16px;
}
.recipe-preview h4 {
color: #fff;
margin-bottom: 12px;
}
.recipe-items {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.recipe-item {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border-radius: 16px;
font-size: 0.85rem;
color: #fff;
}
.recipe-item .arrow {
margin-left: 8px;
color: #666;
}
.empty {
color: #666;
font-style: italic;
}
`}</style>
</div>
);
};
export default ModuleFlow;

View File

View File

View File

View File

@@ -0,0 +1,254 @@
/**
* USA Territory Map Component
*
* Interactive map showing campaign coverage by state.
* Uses React-Leaflet for rendering.
*/
import React, { useEffect, useState } from 'react';
interface StateData {
state: string;
code: string;
articleCount: number;
status: 'empty' | 'active' | 'saturated';
}
interface WarMapProps {
onStateClick?: (stateCode: string) => void;
}
// US State coordinates (simplified centroids)
const STATE_COORDS: Record<string, [number, number]> = {
'AL': [32.806671, -86.791130], 'AK': [61.370716, -152.404419],
'AZ': [33.729759, -111.431221], 'AR': [34.969704, -92.373123],
'CA': [36.116203, -119.681564], 'CO': [39.059811, -105.311104],
'CT': [41.597782, -72.755371], 'DE': [39.318523, -75.507141],
'FL': [27.766279, -81.686783], 'GA': [33.040619, -83.643074],
'HI': [21.094318, -157.498337], 'ID': [44.240459, -114.478828],
'IL': [40.349457, -88.986137], 'IN': [39.849426, -86.258278],
'IA': [42.011539, -93.210526], 'KS': [38.526600, -96.726486],
'KY': [37.668140, -84.670067], 'LA': [31.169546, -91.867805],
'ME': [44.693947, -69.381927], 'MD': [39.063946, -76.802101],
'MA': [42.230171, -71.530106], 'MI': [43.326618, -84.536095],
'MN': [45.694454, -93.900192], 'MS': [32.741646, -89.678696],
'MO': [38.456085, -92.288368], 'MT': [46.921925, -110.454353],
'NE': [41.125370, -98.268082], 'NV': [38.313515, -117.055374],
'NH': [43.452492, -71.563896], 'NJ': [40.298904, -74.521011],
'NM': [34.840515, -106.248482], 'NY': [42.165726, -74.948051],
'NC': [35.630066, -79.806419], 'ND': [47.528912, -99.784012],
'OH': [40.388783, -82.764915], 'OK': [35.565342, -96.928917],
'OR': [44.572021, -122.070938], 'PA': [40.590752, -77.209755],
'RI': [41.680893, -71.511780], 'SC': [33.856892, -80.945007],
'SD': [44.299782, -99.438828], 'TN': [35.747845, -86.692345],
'TX': [31.054487, -97.563461], 'UT': [40.150032, -111.862434],
'VT': [44.045876, -72.710686], 'VA': [37.769337, -78.169968],
'WA': [47.400902, -121.490494], 'WV': [38.491226, -80.954453],
'WI': [44.268543, -89.616508], 'WY': [42.755966, -107.302490]
};
const STATE_NAMES: Record<string, string> = {
'AL': 'Alabama', 'AK': 'Alaska', 'AZ': 'Arizona', 'AR': 'Arkansas',
'CA': 'California', 'CO': 'Colorado', 'CT': 'Connecticut', 'DE': 'Delaware',
'FL': 'Florida', 'GA': 'Georgia', 'HI': 'Hawaii', 'ID': 'Idaho',
'IL': 'Illinois', 'IN': 'Indiana', 'IA': 'Iowa', 'KS': 'Kansas',
'KY': 'Kentucky', 'LA': 'Louisiana', 'ME': 'Maine', 'MD': 'Maryland',
'MA': 'Massachusetts', 'MI': 'Michigan', 'MN': 'Minnesota', 'MS': 'Mississippi',
'MO': 'Missouri', 'MT': 'Montana', 'NE': 'Nebraska', 'NV': 'Nevada',
'NH': 'New Hampshire', 'NJ': 'New Jersey', 'NM': 'New Mexico', 'NY': 'New York',
'NC': 'North Carolina', 'ND': 'North Dakota', 'OH': 'Ohio', 'OK': 'Oklahoma',
'OR': 'Oregon', 'PA': 'Pennsylvania', 'RI': 'Rhode Island', 'SC': 'South Carolina',
'SD': 'South Dakota', 'TN': 'Tennessee', 'TX': 'Texas', 'UT': 'Utah',
'VT': 'Vermont', 'VA': 'Virginia', 'WA': 'Washington', 'WV': 'West Virginia',
'WI': 'Wisconsin', 'WY': 'Wyoming'
};
export const WarMap: React.FC<WarMapProps> = ({ onStateClick }) => {
const [stateData, setStateData] = useState<StateData[]>([]);
const [hoveredState, setHoveredState] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadStateData();
}, []);
const loadStateData = async () => {
try {
const response = await fetch('/api/seo/coverage-by-state');
const data = await response.json();
if (data.states) {
setStateData(data.states);
} else {
// Generate mock data for demo
const mockData = Object.entries(STATE_COORDS).map(([code]) => ({
state: STATE_NAMES[code] || code,
code,
articleCount: Math.floor(Math.random() * 500),
status: ['empty', 'active', 'saturated'][Math.floor(Math.random() * 3)] as any
}));
setStateData(mockData);
}
} catch (err) {
console.error('Failed to load state data:', err);
} finally {
setLoading(false);
}
};
const getStateColor = (status: string): string => {
switch (status) {
case 'saturated': return '#ef4444'; // Red
case 'active': return '#22c55e'; // Green
default: return '#6b7280'; // Gray
}
};
const getStateSize = (count: number): number => {
if (count > 200) return 24;
if (count > 100) return 20;
if (count > 50) return 16;
if (count > 0) return 12;
return 8;
};
if (loading) {
return (
<div className="war-map-loading">
<span>Loading territory map...</span>
</div>
);
}
return (
<div className="war-map-container">
<svg
viewBox="0 0 960 600"
className="war-map-svg"
style={{ width: '100%', height: '400px' }}
>
{/* Simplified US outline */}
<rect x="0" y="0" width="960" height="600" fill="#0a0a0f" />
{/* State markers */}
{stateData.map((state) => {
const coords = STATE_COORDS[state.code];
if (!coords) return null;
// Convert lat/lng to SVG coordinates (simplified)
const x = ((coords[1] + 125) / 60) * 900 + 30;
const y = ((50 - coords[0]) / 25) * 500 + 50;
const size = getStateSize(state.articleCount);
const color = getStateColor(state.status);
return (
<g
key={state.code}
className="state-marker"
onClick={() => onStateClick?.(state.code)}
onMouseEnter={() => setHoveredState(state.code)}
onMouseLeave={() => setHoveredState(null)}
style={{ cursor: 'pointer' }}
>
<circle
cx={x}
cy={y}
r={size}
fill={color}
opacity={hoveredState === state.code ? 1 : 0.7}
stroke={hoveredState === state.code ? '#fff' : 'transparent'}
strokeWidth={2}
/>
<text
x={x}
y={y + 4}
textAnchor="middle"
fill="#fff"
fontSize="10"
fontWeight="bold"
>
{state.code}
</text>
{hoveredState === state.code && (
<g>
<rect
x={x - 50}
y={y - 50}
width={100}
height={35}
rx={4}
fill="#1a1a2e"
stroke="#333"
/>
<text x={x} y={y - 35} textAnchor="middle" fill="#fff" fontSize="12">
{state.state}
</text>
<text x={x} y={y - 20} textAnchor="middle" fill="#888" fontSize="10">
{state.articleCount} articles
</text>
</g>
)}
</g>
);
})}
</svg>
{/* Legend */}
<div className="map-legend">
<div className="legend-item">
<span className="legend-dot" style={{ background: '#6b7280' }}></span>
<span>No Coverage</span>
</div>
<div className="legend-item">
<span className="legend-dot" style={{ background: '#22c55e' }}></span>
<span>Active Campaign</span>
</div>
<div className="legend-item">
<span className="legend-dot" style={{ background: '#ef4444' }}></span>
<span>Saturated</span>
</div>
</div>
<style>{`
.war-map-container {
background: rgba(0,0,0,0.3);
border-radius: 12px;
padding: 20px;
}
.war-map-loading {
display: flex;
align-items: center;
justify-content: center;
height: 400px;
color: #888;
}
.war-map-svg {
border-radius: 8px;
}
.state-marker {
transition: all 0.2s ease;
}
.map-legend {
display: flex;
justify-content: center;
gap: 24px;
margin-top: 16px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
color: #888;
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
`}</style>
</div>
);
};
export default WarMap;

View File

@@ -0,0 +1,99 @@
import React from 'react';
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Users, FileText, Star, Trophy } from 'lucide-react';
import type { AvatarMetric } from '@/lib/intelligence/types';
import {
Table,
TableHeader,
TableRow,
TableHead,
TableBody,
TableCell
} from "@/components/ui/table"; // Assuming table exists, if not I will fallback to divs. Based on list_dir earlier, table.tsx exists.
interface AvatarMetricsProps {
metrics: AvatarMetric[];
isLoading?: boolean;
}
const AvatarMetrics = ({ metrics, isLoading = false }: AvatarMetricsProps) => {
// Sort by engagement
const sortedMetrics = [...metrics].sort((a, b) => b.avg_engagement - a.avg_engagement);
const topPerformer = sortedMetrics[0];
if (isLoading) {
return (
<Card className="h-[400px] flex items-center justify-center border-border/50 bg-card/50 backdrop-blur-sm">
<div className="flex flex-col items-center gap-4 text-muted-foreground">
<Users className="h-10 w-10 animate-pulse text-primary/50" />
<p>Analyzing avatar performance...</p>
</div>
</Card>
);
}
return (
<Card className="border-border/50 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-colors">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2 text-lg">
<Users className="h-5 w-5 text-purple-500" />
Avatar Performance
</CardTitle>
{topPerformer && (
<Badge variant="outline" className="bg-purple-500/10 text-purple-500 border-purple-500/20 gap-1">
<Trophy className="h-3 w-3" />
Top: {topPerformer.name}
</Badge>
)}
</CardHeader>
<CardContent>
<div className="rounded-md border border-border/50">
<Table>
<TableHeader className="bg-background/50">
<TableRow>
<TableHead>Avatar Name</TableHead>
<TableHead>Top Niche</TableHead>
<TableHead className="text-right">Articles</TableHead>
<TableHead className="text-right">Engagement</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedMetrics.map((avatar) => (
<TableRow key={avatar.id} className="hover:bg-muted/50">
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-xs text-white font-bold">
{avatar.name.charAt(0)}
</div>
{avatar.name}
</div>
</TableCell>
<TableCell>
<Badge variant="secondary" className="text-xs">
{avatar.top_niche}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<FileText className="h-3 w-3 text-muted-foreground" />
{avatar.articles_generated}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1 font-bold">
{avatar.avg_engagement > 0.8 && <Star className="h-3 w-3 text-yellow-500 fill-yellow-500" />}
{(avatar.avg_engagement * 100).toFixed(1)}%
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
};
export default AvatarMetrics;

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { MapContainer, TileLayer, CircleMarker, Popup } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
const locations = [
{ id: 1, city: 'New York', lat: 40.7128, lng: -74.0060, value: 95 },
{ id: 2, city: 'Los Angeles', lat: 34.0522, lng: -118.2437, value: 88 },
{ id: 3, city: 'Chicago', lat: 41.8781, lng: -87.6298, value: 76 },
{ id: 4, city: 'Houston', lat: 29.7604, lng: -95.3698, value: 65 },
{ id: 5, city: 'Miami', lat: 25.7617, lng: -80.1918, value: 92 },
];
export const GeoMap = () => {
return (
<Card className="h-[600px] overflow-hidden border-border/50 relative z-0">
<MapContainer
center={[39.8283, -98.5795]}
zoom={4}
scrollWheelZoom={false}
style={{ height: "100%", width: "100%", zIndex: 0 }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
/>
{locations.map((loc) => (
<CircleMarker
key={loc.id}
center={[loc.lat, loc.lng]}
pathOptions={{
color: loc.value > 90 ? '#22c55e' : loc.value > 80 ? '#eab308' : '#3b82f6',
fillColor: loc.value > 90 ? '#22c55e' : loc.value > 80 ? '#eab308' : '#3b82f6',
fillOpacity: 0.5
}}
radius={Math.max(5, loc.value / 4)}
>
<Popup className="text-black">
<div className="font-bold">{loc.city}</div>
<div>Market Dominance: {loc.value}%</div>
</Popup>
</CircleMarker>
))}
</MapContainer>
<div className="absolute bottom-4 left-4 bg-card/80 backdrop-blur p-4 rounded border border-border/50 z-[1000]">
<h3 className="font-bold mb-2 text-xs uppercase tracking-wider">Dominance Key</h3>
<div className="space-y-2 text-xs">
<div className="flex items-center gap-2"><div className="w-3 h-3 rounded-full bg-green-500"></div> &gt; 90% (Dominant)</div>
<div className="flex items-center gap-2"><div className="w-3 h-3 rounded-full bg-yellow-500"></div> 80-90% (Strong)</div>
<div className="flex items-center gap-2"><div className="w-3 h-3 rounded-full bg-blue-500"></div> &lt; 80% (Growing)</div>
</div>
</div>
</Card>
);
};

View File

@@ -0,0 +1,153 @@
import React, { useEffect, useState } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { MapContainer, TileLayer, CircleMarker, Popup, useMap } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import { Globe, Users, MapPin } from 'lucide-react';
import type { GeoCluster } from '@/lib/intelligence/types';
// Fix for default marker icons in Leaflet with Next.js/Webpack
// Bubbling up this style import to a global effect might be needed, but local import helps
// Note: We use CircleMarkers to avoid the icon image loading issue altogether for this implementation
interface GeoTargetingProps {
clusters: GeoCluster[];
isLoading?: boolean;
}
const SetViewOnClick = ({ coords, zoom }: { coords: [number, number], zoom: number }) => {
const map = useMap();
map.setView(coords, zoom);
return null;
}
const GeoTargeting = ({ clusters, isLoading = false }: GeoTargetingProps) => {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
if (isLoading) {
return (
<Card className="h-[500px] flex items-center justify-center border-border/50 bg-card/50 backdrop-blur-sm">
<div className="flex flex-col items-center gap-4 text-muted-foreground">
<Globe className="h-10 w-10 animate-spin text-primary/50" />
<p>Loading geospatial intelligence...</p>
</div>
</Card>
);
}
if (!isClient) {
return (
<Card className="h-[500px] flex items-center justify-center border-border/50 bg-card/50 backdrop-blur-sm">
<p>Initializing Map...</p>
</Card>
);
}
return (
<Card className="border-border/50 bg-card/50 backdrop-blur-sm overflow-hidden flex flex-col h-full">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-lg">
<Globe className="h-5 w-5 text-blue-500" />
Global Audience Clusters
</CardTitle>
<Badge variant="outline" className="bg-blue-500/10 text-blue-500 border-blue-500/20">
{clusters.length} Active Regions
</Badge>
</div>
</CardHeader>
<CardContent className="p-0 relative flex-1 min-h-[400px]">
<MapContainer
center={[20, 0]}
zoom={2}
scrollWheelZoom={false}
style={{ height: '100%', width: '100%', minHeight: '400px', zIndex: 0 }}
className="bg-[#1a1a1a]"
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>'
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
/>
{clusters.map((cluster) => {
// Extract coords from location string "lat, lng" or mock logic if just a name
// For this implementation, we'll try to parse or use a mock lookup
// We will check if the location field has coordinates, else randomize near a common point for demo
// Real implementation would parse proper lat/lng
// Mocking coords if not provided in "lat,lng" format
const mockCoords: [number, number] = [
Math.random() * 60 - 30 + 30, // Random lat
Math.random() * 100 - 50 // Random lng
];
const parseCoords = (loc: string): [number, number] => {
if (loc.includes(',')) {
const [lat, lng] = loc.split(',').map(n => parseFloat(n.trim()));
if (!isNaN(lat) && !isNaN(lng)) return [lat, lng];
}
return mockCoords;
};
const position = parseCoords(cluster.location);
return (
<CircleMarker
key={cluster.id}
center={position}
pathOptions={{
color: 'hsl(var(--primary))',
fillColor: 'hsl(var(--primary))',
fillOpacity: 0.6
}}
radius={Math.log(cluster.audience_size) * 2} // Scale radius by audience size
>
<Popup className="cluster-popup">
<div className="p-2 space-y-1">
<h3 className="font-bold flex items-center gap-2">
<MapPin className="h-3 w-3" /> {cluster.name}
</h3>
<div className="text-xs text-muted-foreground">{cluster.location}</div>
<div className="grid grid-cols-2 gap-2 mt-2 text-xs">
<div className="bg-background/10 p-1 rounded">
<div className="text-muted-foreground">Audience</div>
<div className="font-bold">{cluster.audience_size.toLocaleString()}</div>
</div>
<div className="bg-background/10 p-1 rounded">
<div className="text-muted-foreground">Engagement</div>
<div className="font-bold">{(cluster.engagement_rate * 100).toFixed(1)}%</div>
</div>
</div>
<div className="mt-2 text-xs border-t pt-1 border-gray-100/20">
Dominant: <span className="text-primary">{cluster.dominant_topic}</span>
</div>
</div>
</Popup>
</CircleMarker>
);
})}
</MapContainer>
{/* Overlay Stats */}
<div className="absolute bottom-4 left-4 z-[400] bg-background/80 backdrop-blur-md p-3 rounded-lg border border-border/50 shadow-lg max-w-[200px]">
<div className="text-xs uppercase text-muted-foreground font-semibold mb-2">Top Region</div>
{clusters.length > 0 && (
<div className="flex items-center gap-3">
<Users className="h-8 w-8 text-primary opacity-80" />
<div>
<div className="font-bold text-sm truncate">{clusters[0].name}</div>
<div className="text-xs text-primary">{clusters[0].dominant_topic}</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
);
};
export default GeoTargeting;

View File

@@ -0,0 +1,175 @@
import React, { useState, useEffect } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
RadarChart,
Radar,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis
} from 'recharts';
import type { Pattern } from '@/lib/intelligence/types';
import { Brain, TrendingUp, Zap } from 'lucide-react';
interface PatternAnalyzerProps {
patterns: Pattern[];
isLoading?: boolean;
}
const PatternAnalyzer = ({ patterns, isLoading = false }: PatternAnalyzerProps) => {
// Top patterns by confidence
const topPatterns = [...patterns]
.sort((a, b) => b.confidence - a.confidence)
.slice(0, 5);
const typeDistribution = patterns.reduce((acc, curr) => {
acc[curr.type] = (acc[curr.type] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const radarData = Object.entries(typeDistribution).map(([key, value]) => ({
subject: key.charAt(0).toUpperCase() + key.slice(1),
A: value,
fullMark: Math.max(...Object.values(typeDistribution)) * 1.2
}));
if (isLoading) {
return (
<Card className="h-[400px] flex items-center justify-center border-border/50 bg-card/50 backdrop-blur-sm">
<div className="flex flex-col items-center gap-4 text-muted-foreground">
<Brain className="h-10 w-10 animate-pulse text-primary/50" />
<p>Analyzing behavioral patterns...</p>
</div>
</Card>
);
}
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Confidence Chart */}
<Card className="border-border/50 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-colors">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<TrendingUp className="h-5 w-5 text-primary" />
Pattern Confidence Scores
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={topPatterns} layout="vertical" margin={{ left: 20 }}>
<CartesianGrid strokeDasharray="3 3" opacity={0.1} />
<XAxis type="number" domain={[0, 1]} hide />
<YAxis
type="category"
dataKey="name"
width={100}
tick={{ fontSize: 12, fill: '#888' }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1a1a1a',
border: '1px solid #333',
borderRadius: '6px'
}}
/>
<Bar
dataKey="confidence"
fill="hsl(var(--primary))"
radius={[0, 4, 4, 0]}
barSize={20}
/>
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Type Distribution Radar */}
<Card className="border-border/50 bg-card/50 backdrop-blur-sm hover:border-primary/20 transition-colors">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Zap className="h-5 w-5 text-warning" />
Pattern Type Distribution
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%">
<RadarChart cx="50%" cy="50%" outerRadius="80%" data={radarData}>
<PolarGrid opacity={0.2} />
<PolarAngleAxis dataKey="subject" tick={{ fill: '#888', fontSize: 12 }} />
<PolarRadiusAxis angle={30} domain={[0, 'auto']} hide />
<Radar
name="Patterns"
dataKey="A"
stroke="hsl(var(--primary))"
strokeWidth={2}
fill="hsl(var(--primary))"
fillOpacity={0.3}
/>
<Tooltip
contentStyle={{
backgroundColor: '#1a1a1a',
border: '1px solid #333',
borderRadius: '6px'
}}
/>
</RadarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Recent Discoveries List */}
<Card className="col-span-1 lg:col-span-2 border-border/50 bg-card/50 backdrop-blur-sm">
<CardHeader>
<CardTitle className="text-lg">Recent Discoveries</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{patterns.slice(0, 3).map((pattern) => (
<div key={pattern.id} className="flex items-center justify-between p-3 rounded-lg bg-background/50 border border-border/50">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-full ${pattern.type === 'structure' ? 'bg-blue-500/10 text-blue-500' :
pattern.type === 'semantic' ? 'bg-purple-500/10 text-purple-500' :
'bg-green-500/10 text-green-500'
}`}>
<Brain className="h-4 w-4" />
</div>
<div>
<div className="font-medium text-sm">{pattern.name}</div>
<div className="text-xs text-muted-foreground flex items-center gap-2">
{pattern.tags.map(tag => (
<span key={tag}>#{tag}</span>
))}
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<div className="text-sm font-bold">{(pattern.confidence * 100).toFixed(0)}%</div>
<div className="text-xs text-muted-foreground">Confidence</div>
</div>
<Badge variant="outline" className="bg-background/50">
{pattern.occurrences} hits
</Badge>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
};
export default PatternAnalyzer;

View File

View File

View File

@@ -0,0 +1,14 @@
---
import AdminLayout from '@/layouts/AdminLayout.astro';
import { MetricsDashboard } from '@/components/analytics/MetricsDashboard';
---
<AdminLayout title="Advanced Analytics">
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Command Center Analytics</h1>
<p className="text-slate-400">Real-time deep dive into platform performance metrics.</p>
</div>
<MetricsDashboard client:only="react" />
</div>
</AdminLayout>

View File

@@ -0,0 +1,17 @@
---
import AdminLayout from '@/layouts/AdminLayout.astro';
import TemplateComposer from '@/components/assembler/TemplateComposer';
---
<AdminLayout title="Template Composer">
<div className="h-full flex flex-col space-y-4">
<div>
<h1 className="text-2xl font-bold text-white mb-1">Assembler Station</h1>
<p className="text-slate-400 text-sm">Design intelligent content templates with Spintax and Variables.</p>
</div>
<div className="flex-1 min-h-0">
<TemplateComposer client:only="react" />
</div>
</div>
</AdminLayout>

View File

View File

@@ -0,0 +1,15 @@
---
import AdminLayout from '@/layouts/AdminLayout.astro';
import BulkGenerator from '@/components/assembler/BulkGenerator';
---
<AdminLayout title="Content Assembly Workflow">
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Bulk Assembly</h1>
<p className="text-slate-400">Generate hundreds of articles using your templates and data sources.</p>
</div>
<BulkGenerator client:only="react" />
</div>
</AdminLayout>

View File

@@ -0,0 +1,14 @@
---
import AdminLayout from '@/layouts/AdminLayout.astro';
import AutomationBuilder from '@/components/automations/AutomationBuilder';
---
<AdminLayout title="Visual Automation Builder">
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Workflow Automations</h1>
<p className="text-slate-400">Visually design complex content pipelines.</p>
</div>
<AutomationBuilder client:only="react" />
</div>
</AdminLayout>

View File

@@ -0,0 +1,17 @@
---
import AdminLayout from '@/layouts/AdminLayout.astro';
import VisualBlockEditor from '@/components/blocks/VisualBlockEditor';
---
<AdminLayout title="Visual Block Editor">
<div className="h-full flex flex-col space-y-4">
<div>
<h1 className="text-2xl font-bold text-white mb-1">Visual Page Builder</h1>
<p className="text-slate-400 text-sm">Drag and drop blocks to design article templates and landing pages.</p>
</div>
<div className="flex-1 min-h-0">
<VisualBlockEditor client:only="react" />
</div>
</div>
</AdminLayout>

View File

View File

@@ -0,0 +1,20 @@
---
import Layout from '@/layouts/AdminLayout.astro';
import AvatarVariantsManager from '@/components/admin/intelligence/AvatarVariantsManager';
---
<Layout title="Avatar Variants | Spark Intelligence">
<div class="p-8 space-y-8">
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold text-white tracking-tight">🧬 Variant Laboratory</h1>
<p class="text-zinc-400 mt-2 max-w-2xl">
Fine-tune specific persona variations. Create "Aggressive" sales clones or "Empathetic" support agents
derived from your base Avatars.
</p>
</div>
</div>
<AvatarVariantsManager client:load />
</div>
</Layout>

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

View File

@@ -0,0 +1,20 @@
---
import Layout from '@/layouts/AdminLayout.astro';
import CartesianManager from '@/components/admin/intelligence/CartesianManager';
---
<Layout title="Cartesian Patterns | Spark Intelligence">
<div class="p-8 space-y-6">
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold text-white tracking-tight">📐 Cartesian Patterns</h1>
<p class="text-zinc-400 mt-2 max-w-2xl">
Create logic-based sentence formulas. Combine text, spintax, and variables like
<code>&#123;city&#125;</code> or <code>&#123;service&#125;</code> to generate millions of unique combinations.
</p>
</div>
</div>
<CartesianManager client:only="react" />
</div>
</Layout>

View File

@@ -0,0 +1,20 @@
---
import Layout from '@/layouts/AdminLayout.astro';
import GenericCollectionManager from '@/components/admin/collections/GenericCollectionManager';
---
<Layout title="Content Fragments | Spark Intelligence">
<div class="p-8">
<GenericCollectionManager
client:only="react"
collection="content_fragments"
title="Content Fragments"
displayField="key"
fields={[
{ key: 'key', label: 'Fragment Key', type: 'text' },
{ key: 'content', label: 'Content', type: 'textarea' },
{ key: 'tags', label: 'Tags (JSON)', type: 'json' }
]}
/>
</div>
</Layout>

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

View File

@@ -0,0 +1,21 @@
---
import Layout from '@/layouts/AdminLayout.astro';
import GeoIntelligenceManager from '@/components/admin/intelligence/GeoIntelligenceManager';
import { CoreProvider } from '@/components/providers/CoreProviders';
---
<Layout title="Geo Intelligence | Spark Platform">
<div class="p-8 space-y-6">
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold text-white tracking-tight">🌍 Geo Intelligence</h1>
<p class="text-zinc-400 mt-2">
Visualize your market dominance. Manage region clusters and target specific cities for localized content campaigns.
</p>
</div>
</div>
<CoreProvider client:load>
<GeoIntelligenceManager client:only="react" />
</CoreProvider>
</div>
</Layout>

View File

@@ -0,0 +1,21 @@
---
import Layout from '@/layouts/AdminLayout.astro';
import GenericCollectionManager from '@/components/admin/collections/GenericCollectionManager';
---
<Layout title="Headlines | Spark Intelligence">
<div class="p-8">
<GenericCollectionManager
client:only="react"
collection="headline_inventory"
title="Headline Inventory"
displayField="text"
fields={[
{ key: 'text', label: 'Headline Text', type: 'text' },
{ key: 'type', label: 'Type (H1/H2)', type: 'text' },
{ key: 'category', label: 'Category', type: 'text' },
{ key: 'spintax_root', label: 'Spintax Root', type: 'text' }
]}
/>
</div>
</Layout>

View File

@@ -0,0 +1,21 @@
---
import Layout from '@/layouts/AdminLayout.astro';
import GenericCollectionManager from '@/components/admin/collections/GenericCollectionManager';
---
<Layout title="Offer Blocks | Spark Intelligence">
<div class="p-8">
<GenericCollectionManager
client:only="react"
collection="offer_blocks"
title="Offer Blocks"
displayField="title"
fields={[
{ key: 'title', label: 'Offer Title', type: 'text' },
{ key: 'hook', label: 'Hook / Generator', type: 'textarea' },
{ key: 'pains', label: 'Pains (JSON)', type: 'json' },
{ key: 'solutions', label: 'Solutions (JSON)', type: 'json' }
]}
/>
</div>
</Layout>

View File

@@ -0,0 +1,21 @@
---
import Layout from '@/layouts/AdminLayout.astro';
import GenericCollectionManager from '@/components/admin/collections/GenericCollectionManager';
---
<Layout title="Page Blocks | Spark Intelligence">
<div class="p-8">
<GenericCollectionManager
client:only="react"
collection="page_blocks"
title="Page Layout Blocks"
displayField="name"
fields={[
{ key: 'name', label: 'Block Name', type: 'text' },
{ key: 'category', label: 'Category', type: 'text' },
{ key: 'html_content', label: 'HTML Structure', type: 'textarea' },
{ key: 'css_content', label: 'CSS / Tailwind', type: 'textarea' }
]}
/>
</div>
</Layout>

View File

@@ -0,0 +1,20 @@
---
import Layout from '@/layouts/AdminLayout.astro';
import SpintaxManager from '@/components/admin/intelligence/SpintaxManager';
---
<Layout title="Spintax Dictionaries | Spark Intelligence">
<div class="p-8 space-y-6">
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold text-white tracking-tight">📚 Spintax Library</h1>
<p class="text-zinc-400 mt-2 max-w-2xl">
Manage your vocabulary variations. Use these sets to generate diverse, non-repetitive content
using <code>&#123;key|word|variant&#125;</code> syntax.
</p>
</div>
</div>
<SpintaxManager client:only="react" />
</div>
</Layout>

View File

@@ -0,0 +1,17 @@
---
import Layout from '@/layouts/AdminLayout.astro';
import AvatarIntelligenceManager from '@/components/admin/intelligence/AvatarIntelligenceManager';
import { CoreProvider } from '@/components/providers/CoreProviders';
---
<Layout title="Avatar Intelligence">
<div class="p-8">
<div class="mb-6">
<h1 class="text-3xl font-bold text-white mb-2">🎭 Avatar Intelligence</h1>
<p class="text-gray-400">Manage your base avatars, variants, and target personas. Each avatar represents a unique customer profile.</p>
</div>
<CoreProvider client:load>
<AvatarIntelligenceManager client:load />
</CoreProvider>
</div>
</Layout>

View File

View File

View File

@@ -0,0 +1,14 @@
---
import Layout from '@/layouts/AdminLayout.astro';
import LogViewer from '@/components/admin/content/LogViewer';
---
<Layout title="System Logs">
<div class="p-8">
<div class="mb-6">
<h1 class="text-3xl font-bold text-white mb-2">System Work Log</h1>
<p class="text-gray-400">Real-time backend execution and activity logs.</p>
</div>
<LogViewer client:load />
</div>
</Layout>

View File

@@ -0,0 +1,9 @@
---
import Layout from '@/layouts/AdminLayout.astro';
import ContentFactoryDashboard from '@/components/admin/content/ContentFactoryDashboard';
---
<Layout title="Factory Command Center">
<div class="p-8">
<ContentFactoryDashboard client:load />
</div>
</Layout>

View File

@@ -0,0 +1,222 @@
/**
* Article Workbench - Article Detail Editor
* 3-panel layout: Metadata | Editor | Tools
*/
---
import AdminLayout from '@/layouts/AdminLayout.astro';
import { getDirectusClient, readItem } from '@/lib/directus/client';
const { id } = Astro.params;
if (!id) {
return Astro.redirect('/admin/factory');
}
const client = getDirectusClient();
let article;
try {
article = await client.request(readItem('generated_articles', id));
} catch (error) {
console.error('Error fetching article:', error);
return Astro.redirect('/admin/factory');
}
---
<AdminLayout title={`Edit: ${article.title}`}>
<div class="h-[calc(100vh-80px)] flex gap-6">
<!-- Left Panel: Metadata -->
<div class="w-80 flex-shrink-0 space-y-4 overflow-y-auto">
<div class="spark-card p-6">
<h3 class="spark-heading text-lg mb-4">Metadata</h3>
<!-- Status -->
<div class="mb-4">
<label class="spark-label block mb-2">Status</label>
<select class="spark-input w-full">
<option value="queued">Queued</option>
<option value="generating">Generating</option>
<option value="review" selected={article.status === 'review'}>Review</option>
<option value="approved">Approved</option>
<option value="published">Published</option>
</select>
</div>
<!-- Location -->
<div class="mb-4">
<label class="spark-label block mb-2">Location</label>
<div class="text-silver text-sm">
{article.geo_city && article.geo_state
? `${article.geo_city}, ${article.geo_state}`
: 'Not set'}
</div>
</div>
<!-- SEO Score -->
<div class="mb-4">
<label class="spark-label block mb-2">SEO Score</label>
<div class="spark-data text-2xl">
{article.seo_score || 0}/100
</div>
<div class="h-2 bg-graphite rounded-full mt-2 overflow-hidden">
<div
class="h-full bg-gold-gradient rounded-full transition-all"
style={`width: ${article.seo_score || 0}%`}
></div>
</div>
</div>
<!-- Meta Description -->
<div class="mb-4">
<label class="spark-label block mb-2">Meta Description</label>
<textarea
class="spark-input w-full text-sm"
rows="3"
maxlength="160"
placeholder="Write a compelling meta description..."
>{article.meta_desc}</textarea>
<div class="text-silver/50 text-xs mt-1">
{article.meta_desc?.length || 0}/160
</div>
</div>
</div>
</div>
<!-- Center Panel: Editor -->
<div class="flex-1 flex flex-col spark-card overflow-hidden">
<!-- Editor Header -->
<div class="border-b border-edge-subtle p-4 flex items-center justify-between">
<div class="flex gap-2">
<button class="spark-btn-ghost text-sm" id="visual-mode">Visual</button>
<button class="spark-btn-ghost text-sm" id="code-mode">Code</button>
</div>
<div class="flex items-center gap-3">
<span class="text-silver/50 text-xs" id="auto-save-status">Saved</span>
<button class="spark-btn-secondary text-sm">
Save Draft
</button>
</div>
</div>
<!-- Editor Area -->
<div class="flex-1 overflow-y-auto p-6 bg-void">
<!-- Title -->
<input
type="text"
value={article.title}
class="w-full bg-transparent border-none text-3xl font-bold text-white mb-6 focus:outline-none placeholder:text-silver/30"
placeholder="Article title..."
/>
<!-- Content -->
<div id="editor-container" class="prose prose-invert max-w-none">
<div set:html={article.content_html || '<p class="text-silver/50">Start writing...</p>'} />
</div>
</div>
</div>
<!-- Right Panel: Tools -->
<div class="w-80 flex-shrink-0 space-y-4 overflow-y-auto">
<!-- SEO Tools -->
<div class="spark-card p-6">
<h3 class="spark-heading text-lg mb-4">SEO Tools</h3>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-silver text-sm">Readability</span>
<span class="spark-data text-sm">Good</span>
</div>
<div class="flex items-center justify-between">
<span class="text-silver text-sm">Keyword Density</span>
<span class="spark-data text-sm">2.3%</span>
</div>
<div class="flex items-center justify-between">
<span class="text-silver text-sm">Word Count</span>
<span class="spark-data text-sm">1,247</span>
</div>
</div>
</div>
<!-- Spintax Info -->
<div class="spark-card p-6">
<h3 class="spark-heading text-lg mb-4">Spintax</h3>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-silver text-sm">Variations</span>
<span class="spark-data text-sm">432</span>
</div>
<button class="spark-btn-ghost w-full text-sm">
Preview Variations
</button>
</div>
</div>
<!-- Images -->
<div class="spark-card p-6">
<h3 class="spark-heading text-lg mb-4">Featured Image</h3>
{article.featured_image_url ? (
<img
src={article.featured_image_url}
alt="Featured"
class="w-full rounded-lg border border-edge-normal mb-3"
/>
) : (
<div class="w-full aspect-video bg-graphite rounded-lg border border-edge-subtle flex items-center justify-center mb-3">
<span class="text-silver/50 text-sm">No image</span>
</div>
)}
<button class="spark-btn-ghost w-full text-sm">
Generate Image
</button>
</div>
<!-- Logs -->
<div class="spark-card p-6">
<h3 class="spark-heading text-lg mb-4">Activity Log</h3>
<div class="space-y-2 max-h-40 overflow-y-auto">
<div class="text-xs">
<div class="text-silver/50">2 hours ago</div>
<div class="text-silver">Article generated</div>
</div>
<div class="text-xs">
<div class="text-silver/50">1 hour ago</div>
<div class="text-silver">SEO score calculated</div>
</div>
</div>
</div>
</div>
</div>
</AdminLayout>
<script>
// Auto-save functionality
let saveTimeout: number;
const autoSaveStatus = document.getElementById('auto-save-status');
const editorContainer = document.getElementById('editor-container');
if (editorContainer) {
editorContainer.addEventListener('input', () => {
if (autoSaveStatus) {
autoSaveStatus.textContent = 'Saving...';
}
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
// Save logic here
if (autoSaveStatus) {
autoSaveStatus.textContent = 'Saved';
}
}, 2000);
});
}
</script>

View File

@@ -0,0 +1,63 @@
/**
* Factory Floor - Main Production Page
* Kanban/Grid view switcher for articles
*/
---
import AdminLayout from '@/layouts/AdminLayout.astro';
const currentPath = Astro.url.pathname;
const action = Astro.url.searchParams.get('action');
---
<AdminLayout title="Factory Floor">
<div class="space-y-6">
<!-- Header -->
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-white">Factory Floor</h1>
<p class="text-slate-400 mt-1">Content production workflow</p>
</div>
<div class="flex gap-3">
<a
href="/admin/factory?action=new"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
>
✨ New Campaign
</a>
</div>
</div>
<!-- View Switcher -->
<div class="flex gap-2 bg-slate-800 p-1 rounded-lg w-fit">
<a
href="/admin/factory?view=kanban"
class:list={[
'px-4 py-2 rounded transition-colors',
!Astro.url.searchParams.get('view') || Astro.url.searchParams.get('view') === 'kanban'
? 'bg-slate-700 text-white'
: 'text-slate-400 hover:text-white'
]}
>
📊 Kanban
</a>
<a
href="/admin/factory?view=grid"
class:list={[
'px-4 py-2 rounded transition-colors',
Astro.url.searchParams.get('view') === 'grid'
? 'bg-slate-700 text-white'
: 'text-slate-400 hover:text-white'
]}
>
📋 Grid
</a>
</div>
<!-- Content Area -->
<div class="bg-slate-800 border border-slate-700 rounded-lg p-6">
<p class="text-slate-400">Factory view components will load here</p>
<p class="text-slate-500 text-sm mt-2">Kanban Board and Bulk Grid components coming next...</p>
</div>
</div>
</AdminLayout>

View File

@@ -0,0 +1,19 @@
---
import Layout from '@/layouts/AdminLayout.astro';
import JobsManager from '@/components/admin/jobs/JobsManager';
---
<Layout title="Job Queue | Spark Intelligence">
<div class="p-8 space-y-6">
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold text-white tracking-tight">⚙️ Generation Queue</h1>
<p class="text-zinc-400 mt-2 max-w-2xl">
Monitor background processing jobs. Watch content generation progress in real-time.
</p>
</div>
</div>
<JobsManager client:only="react" />
</div>
</Layout>

View File

@@ -0,0 +1,33 @@
---
import Layout from '@/layouts/AdminLayout.astro';
import KanbanBoard from '@/components/admin/factory/KanbanBoard';
import { Button } from '@/components/ui/button';
import { Plus } from 'lucide-react';
---
<Layout title="Content Factory Board | Spark Intelligence">
<div class="h-screen flex flex-col overflow-hidden">
<div className="flex-none p-6 pb-2 flex justify-between items-center border-b border-zinc-800/50 bg-zinc-950/50 backdrop-blur-sm z-10">
<div>
<h1 className="text-2xl font-bold text-white tracking-tight flex items-center gap-2">
🏭 Content Factory
<span className="text-xs font-normal text-zinc-500 bg-zinc-900 border border-zinc-800 px-2 py-0.5 rounded-full">Beta</span>
</h1>
<p className="text-zinc-400 text-sm mt-1">
Drag and drop articles to move them through the production pipeline.
</p>
</div>
<div className="flex gap-2">
<a href="/admin/jumpstart/wizard">
<Button className="bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 border-0">
<Plus className="mr-2 h-4 w-4" /> New Article
</Button>
</a>
</div>
</div>
<div className="flex-1 overflow-hidden p-6 bg-zinc-950">
<KanbanBoard client:only="react" />
</div>
</div>
</Layout>

View File

@@ -0,0 +1,15 @@
---
import AdminLayout from '../../layouts/AdminLayout.astro';
import SystemMonitor from '../../components/admin/dashboard/SystemMonitor';
---
<AdminLayout title="Mission Control">
<div class="p-8">
<div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2">Command Station</h1>
<p class="text-slate-400">System Monitoring, Sub-Station Status, and Content Integrity.</p>
</div>
<SystemMonitor client:load />
</div>
</AdminLayout>

View File

@@ -0,0 +1,107 @@
/**
* Avatar Intelligence Management
* Full CRUD for avatar_intelligence collection
*/
---
import AdminLayout from '@/layouts/AdminLayout.astro';
import { getDirectusClient } from '@/lib/directus/client';
import { readItems } from '@directus/sdk';
const client = getDirectusClient();
let avatars = [];
let error = null;
try {
avatars = await client.request(readItems('avatar_intelligence', {
fields: ['*'],
sort: ['base_name'],
}));
} catch (e) {
console.error('Error fetching avatars:', e);
error = e instanceof Error ? e.message : 'Unknown error';
}
---
<AdminLayout title="Avatar Intelligence">
<div class="space-y-6">
<!-- Header -->
<div class="flex justify-between items-center">
<div>
<h1 class="spark-heading text-3xl">Avatar Intelligence</h1>
<p class="text-silver mt-1">Manage persona profiles and variants</p>
</div>
<div class="flex gap-3">
<button class="spark-btn-secondary text-sm">
📥 Import CSV
</button>
<button class="spark-btn-secondary text-sm">
📤 Export
</button>
<a href="/admin/intelligence/avatars/new" class="spark-btn-primary text-sm">
✨ New Avatar
</a>
</div>
</div>
{error && (
<div class="spark-card p-4 border-red-500 text-red-400">
Error: {error}
</div>
)}
<!-- Stats -->
<div class="grid grid-cols-4 gap-4">
<div class="spark-card p-6">
<div class="spark-label mb-2">Total Avatars</div>
<div class="spark-data text-3xl">{avatars.length}</div>
</div>
</div>
<!-- Avatars Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{avatars.map((avatar: any) => (
<div class="spark-card spark-card-hover p-6">
<div class="flex items-start justify-between mb-4">
<h3 class="text-white font-semibold text-lg">{avatar.base_name}</h3>
<button class="spark-btn-ghost text-xs px-2 py-1">
Edit
</button>
</div>
<div class="space-y-2 text-sm">
<div>
<span class="spark-label">Wealth Cluster:</span>
<span class="text-silver ml-2">{avatar.wealth_cluster || 'Not set'}</span>
</div>
{avatar.business_niches && (
<div>
<span class="spark-label">Niches:</span>
<div class="flex flex-wrap gap-1 mt-1">
{avatar.business_niches.slice(0, 3).map((niche: string) => (
<span class="px-2 py-0.5 bg-graphite border border-edge-subtle rounded text-xs text-silver">
{niche}
</span>
))}
{avatar.business_niches.length > 3 && (
<span class="px-2 py-0.5 text-xs text-silver/50">
+{avatar.business_niches.length - 3} more
</span>
)}
</div>
</div>
)}
</div>
</div>
))}
{avatars.length === 0 && !error && (
<div class="col-span-full spark-card p-12 text-center">
<p class="text-silver/50">No avatars found. Create your first one!</p>
</div>
)}
</div>
</div>
</AdminLayout>

View File

@@ -0,0 +1,21 @@
---
import AdminLayout from '@/layouts/AdminLayout.astro';
import { GeoMap } from '@/components/intelligence/GeoMap';
import { MetricsDashboard } from '@/components/analytics/MetricsDashboard';
---
<AdminLayout title="Geo Intelligence">
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Market Dominance Map</h1>
<p className="text-slate-400">Visualize your campaign performance across different territories.</p>
</div>
<GeoMap client:only="react" />
<div className="mt-8">
<h2 className="text-xl font-bold text-white mb-4">Regional Performance</h2>
<MetricsDashboard client:only="react" />
</div>
</div>
</AdminLayout>

View File

@@ -0,0 +1,80 @@
---
import AdminLayout from '@/layouts/AdminLayout.astro';
import PatternAnalyzer from '@/components/intelligence/PatternAnalyzer';
import GeoTargeting from '@/components/intelligence/GeoTargeting';
import AvatarMetrics from '@/components/intelligence/AvatarMetrics';
import { Brain, Globe, Users } from 'lucide-react';
// Server-side data fetching (simulated for Phase 4 or connected to local API logic)
// In a real scenario, we might call the DB directly here.
// For now, we'll import the mock data logic or just duplicate the mock data for SSR,
// to ensure the page renders with data immediately.
// We can also fetch from our own API if running, but during build, the API might not be up.
// So we will define the initial data here.
const patterns = [
{ id: '1', name: 'High-Value Listicle Structure', type: 'structure', confidence: 0.92, occurrences: 145, last_detected: '2023-10-25', tags: ['listicle', 'viral', 'b2b'] },
{ id: '2', name: 'Emotional Storytelling Hook', type: 'semantic', confidence: 0.88, occurrences: 89, last_detected: '2023-10-26', tags: ['hook', 'emotional', 'intro'] },
{ id: '3', name: 'Data-Backed CTA', type: 'conversion', confidence: 0.76, occurrences: 230, last_detected: '2023-10-24', tags: ['cta', 'sales', 'closing'] },
{ id: '4', name: 'Contrarian Viewpoint', type: 'semantic', confidence: 0.65, occurrences: 54, last_detected: '2023-10-22', tags: ['opinion', 'debate'] },
{ id: '5', name: 'How-To Guide Format', type: 'structure', confidence: 0.95, occurrences: 310, last_detected: '2023-10-27', tags: ['educational', 'long-form'] }
] as any[];
const geoClusters = [
{ id: '1', name: 'North America Tech Hubs', location: '37.7749, -122.4194', audience_size: 154000, engagement_rate: 0.45, dominant_topic: 'SaaS Marketing' },
{ id: '2', name: 'London Finance Sector', location: '51.5074, -0.1278', audience_size: 89000, engagement_rate: 0.38, dominant_topic: 'FinTech' },
{ id: '3', name: 'Singapore Crypto', location: '1.3521, 103.8198', audience_size: 65000, engagement_rate: 0.52, dominant_topic: 'Web3' },
{ id: '4', name: 'Berlin Startup Scene', location: '52.5200, 13.4050', audience_size: 42000, engagement_rate: 0.41, dominant_topic: 'Growth Hacking' },
{ id: '5', name: 'Sydney E-comm', location: '-33.8688, 151.2093', audience_size: 38000, engagement_rate: 0.35, dominant_topic: 'DTC Brands' }
] as any[];
const avatarMetrics = [
{ id: '1', avatar_id: 'a1', name: 'Marketing Max', articles_generated: 45, avg_engagement: 0.82, top_niche: 'SaaS Growth' },
{ id: '2', avatar_id: 'a2', name: 'Finance Fiona', articles_generated: 32, avg_engagement: 0.75, top_niche: 'Personal Finance' },
{ id: '3', avatar_id: 'a3', name: 'Tech Tyler', articles_generated: 68, avg_engagement: 0.68, top_niche: 'AI Tools' },
{ id: '4', avatar_id: 'a4', name: 'Wellness Wendy', articles_generated: 24, avg_engagement: 0.89, top_niche: 'Holistic Health' },
{ id: '5', avatar_id: 'a5', name: 'Crypto Carl', articles_generated: 15, avg_engagement: 0.45, top_niche: 'DeFi' }
] as any[];
---
<AdminLayout title="Intelligence Station">
<div class="space-y-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-purple-600">
Intelligence Headquarters
</h1>
<p class="text-muted-foreground mt-2 text-lg">
Real-time analysis of content performance, audience demographics, and conversion patterns.
</p>
</div>
<div class="flex gap-4">
<div class="bg-card border border-border/50 rounded-lg p-3 text-center min-w-[120px]">
<div class="text-2xl font-bold text-primary">92%</div>
<div class="text-xs text-muted-foreground uppercase tracking-wider">Predictive Acc.</div>
</div>
<div class="bg-card border border-border/50 rounded-lg p-3 text-center min-w-[120px]">
<div class="text-2xl font-bold text-green-500">1.4M</div>
<div class="text-xs text-muted-foreground uppercase tracking-wider">Data Points</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Patterns Section - Spans 2 columns */}
<div class="lg:col-span-2 space-y-6">
<PatternAnalyzer client:load patterns={patterns} />
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<AvatarMetrics client:load metrics={avatarMetrics} />
</div>
</div>
{/* Geo Section - Spans 1 column */}
<div class="lg:col-span-1 space-y-6 h-full">
<GeoTargeting client:only="react" clusters={geoClusters} />
</div>
</div>
</div>
</AdminLayout>

View File

@@ -0,0 +1,19 @@
---
import AdminLayout from '@/layouts/AdminLayout.astro';
import UnderConstruction from '@/components/ui/UnderConstruction';
---
<AdminLayout title="Content Effectiveness">
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-white mb-2">Reports & Analysis</h1>
<p className="text-slate-400">Deep dive into content performance metrics.</p>
</div>
<UnderConstruction
client:only="react"
title="Intelligence Reports"
description="Advanced analytics including conversion tracking, A/B testing results, and heatmaps are coming in the next update."
eta="Planned for Phase 4.1"
/>
</div>
</AdminLayout>

View File

View File

@@ -0,0 +1,19 @@
---
import Layout from '@/layouts/AdminLayout.astro';
import LeadsManager from '@/components/admin/leads/LeadsManager';
---
<Layout title="Leads Management | Spark Intelligence">
<div class="p-8 space-y-6">
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold text-white tracking-tight">👥 Leads & Prospects</h1>
<p class="text-zinc-400 mt-2 max-w-2xl">
Manage incoming leads and track their status from "New" to "Converted".
</p>
</div>
</div>
<LeadsManager client:only="react" />
</div>
</Layout>

View File

@@ -0,0 +1,8 @@
---
import AdminLayout from '../../layouts/AdminLayout.astro';
import LocationBrowser from '../../components/admin/LocationBrowser';
---
<AdminLayout title="Locations">
<LocationBrowser client:load />
</AdminLayout>

View File

@@ -0,0 +1,8 @@
---
import AdminLayout from '@/layouts/AdminLayout.astro';
import ImageTemplateEditor from '../../../components/admin/ImageTemplateEditor';
---
<AdminLayout title="Image Templates">
<ImageTemplateEditor client:load />
</AdminLayout>

View File

@@ -0,0 +1,20 @@
---
import Layout from '@/layouts/AdminLayout.astro';
import PageEditor from '@/components/admin/pages/PageEditor';
const { id } = Astro.params;
---
<Layout title="Edit Page">
<div class="p-6">
<div class="mb-6">
<a href="/admin/pages" class="text-slate-400 hover:text-white flex items-center gap-2 mb-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
Back to Pages
</a>
<h1 class="text-3xl font-bold text-slate-100">Edit Page</h1>
</div>
<PageEditor id={id} client:only="react" />
</div>
</Layout>

View File

@@ -0,0 +1,21 @@
---
import Layout from '@/layouts/AdminLayout.astro';
import PageList from '@/components/admin/pages/PageList';
---
<Layout title="Page Management">
<div class="p-6 space-y-6">
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-slate-100">Pages</h1>
<p class="text-slate-400">Manage static pages across your sites.</p>
</div>
<a href="/admin/pages/new" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
New Page
</a>
</div>
<PageList client:load />
</div>
</Layout>

View File

@@ -0,0 +1,19 @@
---
import Layout from '@/layouts/AdminLayout.astro';
import SchedulerManager from '@/components/admin/scheduler/SchedulerManager';
---
<Layout title="Campaign Scheduler | Spark Intelligence">
<div class="p-8 space-y-6">
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold text-white tracking-tight">📅 Automation Center</h1>
<p class="text-zinc-400 mt-2 max-w-2xl">
Schedule bulk generation campaigns and automated workflows.
</p>
</div>
</div>
<SchedulerManager client:only="react" />
</div>
</Layout>

Some files were not shown because too many files have changed in this diff Show More