God Mode: Sync All Admin Pages & Components. Fix Navigation. Fix Schemas.
This commit is contained in:
0
src/components/analytics/ErrorTracker.tsx
Normal file
0
src/components/analytics/ErrorTracker.tsx
Normal file
69
src/components/analytics/MetricsDashboard.tsx
Normal file
69
src/components/analytics/MetricsDashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
0
src/components/analytics/PerformanceDashboard.tsx
Normal file
0
src/components/analytics/PerformanceDashboard.tsx
Normal file
0
src/components/analytics/UsageStats.tsx
Normal file
0
src/components/analytics/UsageStats.tsx
Normal file
143
src/components/assembler/BulkGenerator.tsx
Normal file
143
src/components/assembler/BulkGenerator.tsx
Normal 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;
|
||||||
0
src/components/assembler/ContentAssembly.tsx
Normal file
0
src/components/assembler/ContentAssembly.tsx
Normal file
0
src/components/assembler/PreviewPanel.tsx
Normal file
0
src/components/assembler/PreviewPanel.tsx
Normal file
0
src/components/assembler/QualityChecker.tsx
Normal file
0
src/components/assembler/QualityChecker.tsx
Normal file
0
src/components/assembler/SEOOptimizer.tsx
Normal file
0
src/components/assembler/SEOOptimizer.tsx
Normal file
0
src/components/assembler/SpintaxExpander.tsx
Normal file
0
src/components/assembler/SpintaxExpander.tsx
Normal file
239
src/components/assembler/TemplateComposer.tsx
Normal file
239
src/components/assembler/TemplateComposer.tsx
Normal 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;
|
||||||
0
src/components/assembler/VariableSubstitution.tsx
Normal file
0
src/components/assembler/VariableSubstitution.tsx
Normal file
123
src/components/automations/AutomationBuilder.tsx
Normal file
123
src/components/automations/AutomationBuilder.tsx
Normal 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;
|
||||||
56
src/components/blocks/BlockColumns.astro
Normal file
56
src/components/blocks/BlockColumns.astro
Normal 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>
|
||||||
72
src/components/blocks/BlockFAQ.astro
Normal file
72
src/components/blocks/BlockFAQ.astro
Normal 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>
|
||||||
165
src/components/blocks/BlockForm.astro
Normal file
165
src/components/blocks/BlockForm.astro
Normal 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>
|
||||||
62
src/components/blocks/BlockGallery.astro
Normal file
62
src/components/blocks/BlockGallery.astro
Normal 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>
|
||||||
78
src/components/blocks/BlockHero.astro
Normal file
78
src/components/blocks/BlockHero.astro
Normal 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>
|
||||||
51
src/components/blocks/BlockMedia.astro
Normal file
51
src/components/blocks/BlockMedia.astro
Normal 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>
|
||||||
207
src/components/blocks/BlockPosts.astro
Normal file
207
src/components/blocks/BlockPosts.astro
Normal 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>
|
||||||
86
src/components/blocks/BlockQuote.astro
Normal file
86
src/components/blocks/BlockQuote.astro
Normal 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>
|
||||||
|
)}
|
||||||
35
src/components/blocks/BlockRichText.astro
Normal file
35
src/components/blocks/BlockRichText.astro
Normal 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>
|
||||||
86
src/components/blocks/BlockSteps.astro
Normal file
86
src/components/blocks/BlockSteps.astro
Normal 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>
|
||||||
0
src/components/blocks/CTABlock.tsx
Normal file
0
src/components/blocks/CTABlock.tsx
Normal file
0
src/components/blocks/FAQBlock.tsx
Normal file
0
src/components/blocks/FAQBlock.tsx
Normal file
0
src/components/blocks/FeaturesBlock.tsx
Normal file
0
src/components/blocks/FeaturesBlock.tsx
Normal file
0
src/components/blocks/HeroBlock.tsx
Normal file
0
src/components/blocks/HeroBlock.tsx
Normal file
0
src/components/blocks/ImageBlock.tsx
Normal file
0
src/components/blocks/ImageBlock.tsx
Normal file
0
src/components/blocks/OfferBlock.tsx
Normal file
0
src/components/blocks/OfferBlock.tsx
Normal file
0
src/components/blocks/PricingBlock.tsx
Normal file
0
src/components/blocks/PricingBlock.tsx
Normal file
0
src/components/blocks/RichTextBlock.tsx
Normal file
0
src/components/blocks/RichTextBlock.tsx
Normal file
0
src/components/blocks/StatsBlock.tsx
Normal file
0
src/components/blocks/StatsBlock.tsx
Normal file
0
src/components/blocks/TestimonialBlock.tsx
Normal file
0
src/components/blocks/TestimonialBlock.tsx
Normal file
133
src/components/blocks/UserBlocks.tsx
Normal file
133
src/components/blocks/UserBlocks.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
65
src/components/blocks/VisualBlockEditor.tsx
Normal file
65
src/components/blocks/VisualBlockEditor.tsx
Normal 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;
|
||||||
90
src/components/blocks/editor/Panels.tsx
Normal file
90
src/components/blocks/editor/Panels.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
105
src/components/blocks/editor/UserBlocks.tsx
Normal file
105
src/components/blocks/editor/UserBlocks.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
0
src/components/blocks/index.ts
Normal file
0
src/components/blocks/index.ts
Normal file
211
src/components/collections/CollectionManager.tsx
Normal file
211
src/components/collections/CollectionManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
0
src/components/factory/BlockEditor.tsx
Normal file
0
src/components/factory/BlockEditor.tsx
Normal file
221
src/components/factory/BulkGrid.tsx
Normal file
221
src/components/factory/BulkGrid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
134
src/components/factory/KanbanBoard.tsx
Normal file
134
src/components/factory/KanbanBoard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
src/components/factory/KanbanCard.tsx
Normal file
90
src/components/factory/KanbanCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
372
src/components/factory/ModuleFlow.tsx
Normal file
372
src/components/factory/ModuleFlow.tsx
Normal 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;
|
||||||
0
src/components/factory/PageRenderer.tsx
Normal file
0
src/components/factory/PageRenderer.tsx
Normal file
0
src/components/factory/SettingsPanel.tsx
Normal file
0
src/components/factory/SettingsPanel.tsx
Normal file
0
src/components/factory/Toolbox.tsx
Normal file
0
src/components/factory/Toolbox.tsx
Normal file
254
src/components/factory/WarMap.tsx
Normal file
254
src/components/factory/WarMap.tsx
Normal 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;
|
||||||
99
src/components/intelligence/AvatarMetrics.tsx
Normal file
99
src/components/intelligence/AvatarMetrics.tsx
Normal 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;
|
||||||
58
src/components/intelligence/GeoMap.tsx
Normal file
58
src/components/intelligence/GeoMap.tsx
Normal 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='© <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> > 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> < 80% (Growing)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
153
src/components/intelligence/GeoTargeting.tsx
Normal file
153
src/components/intelligence/GeoTargeting.tsx
Normal 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='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <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;
|
||||||
0
src/components/intelligence/KeywordResearch.tsx
Normal file
0
src/components/intelligence/KeywordResearch.tsx
Normal file
175
src/components/intelligence/PatternAnalyzer.tsx
Normal file
175
src/components/intelligence/PatternAnalyzer.tsx
Normal 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;
|
||||||
0
src/components/intelligence/TrendChart.tsx
Normal file
0
src/components/intelligence/TrendChart.tsx
Normal file
0
src/pages/admin/analytics/errors.astro
Normal file
0
src/pages/admin/analytics/errors.astro
Normal file
0
src/pages/admin/analytics/index.astro
Normal file
0
src/pages/admin/analytics/index.astro
Normal file
14
src/pages/admin/analytics/metrics.astro
Normal file
14
src/pages/admin/analytics/metrics.astro
Normal 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>
|
||||||
0
src/pages/admin/analytics/performance.astro
Normal file
0
src/pages/admin/analytics/performance.astro
Normal file
0
src/pages/admin/assembler/bulk-generate.astro
Normal file
0
src/pages/admin/assembler/bulk-generate.astro
Normal file
17
src/pages/admin/assembler/composer.astro
Normal file
17
src/pages/admin/assembler/composer.astro
Normal 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>
|
||||||
0
src/pages/admin/assembler/index.astro
Normal file
0
src/pages/admin/assembler/index.astro
Normal file
0
src/pages/admin/assembler/quality-check.astro
Normal file
0
src/pages/admin/assembler/quality-check.astro
Normal file
15
src/pages/admin/assembler/workflow.astro
Normal file
15
src/pages/admin/assembler/workflow.astro
Normal 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>
|
||||||
14
src/pages/admin/automations/workflow.astro
Normal file
14
src/pages/admin/automations/workflow.astro
Normal 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>
|
||||||
17
src/pages/admin/blocks/editor.astro
Normal file
17
src/pages/admin/blocks/editor.astro
Normal 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>
|
||||||
0
src/pages/admin/campaigns/index.astro
Normal file
0
src/pages/admin/campaigns/index.astro
Normal file
20
src/pages/admin/collections/avatar-variants.astro
Normal file
20
src/pages/admin/collections/avatar-variants.astro
Normal 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>
|
||||||
151
src/pages/admin/collections/campaign-masters.astro
Normal file
151
src/pages/admin/collections/campaign-masters.astro
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* Campaign Masters Management
|
||||||
|
* Full CRUD for campaign_masters collection
|
||||||
|
*/
|
||||||
|
import AdminLayout from '@/layouts/AdminLayout.astro';
|
||||||
|
import { getDirectusClient } from '@/lib/directus/client';
|
||||||
|
import { readItems } from '@directus/sdk';
|
||||||
|
|
||||||
|
const client = getDirectusClient();
|
||||||
|
|
||||||
|
let campaigns = [];
|
||||||
|
let error = null;
|
||||||
|
let stats = {
|
||||||
|
total: 0,
|
||||||
|
active: 0,
|
||||||
|
draft: 0,
|
||||||
|
completed: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
campaigns = await client.request(readItems('campaign_masters', {
|
||||||
|
fields: ['*'],
|
||||||
|
sort: ['-date_created'],
|
||||||
|
}));
|
||||||
|
|
||||||
|
stats.total = campaigns.length;
|
||||||
|
stats.active = campaigns.filter((c: any) => c.status === 'active').length;
|
||||||
|
stats.draft = campaigns.filter((c: any) => c.status === 'draft').length;
|
||||||
|
stats.completed = campaigns.filter((c: any) => c.status === 'completed').length;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching campaigns:', e);
|
||||||
|
error = e instanceof Error ? e.message : 'Unknown error';
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<AdminLayout title="Campaign Masters">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="spark-heading text-3xl">📢 Campaign Masters</h1>
|
||||||
|
<p class="text-silver mt-1">Manage marketing campaigns and content strategies</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button class="spark-btn-secondary text-sm" onclick="window.dispatchEvent(new CustomEvent('import-modal'))">
|
||||||
|
📥 Import
|
||||||
|
</button>
|
||||||
|
<button class="spark-btn-secondary text-sm" onclick="window.dispatchEvent(new CustomEvent('export-data', {detail: {collection: 'campaign_masters'}}))">
|
||||||
|
📤 Export
|
||||||
|
</button>
|
||||||
|
<a href="/admin/collections/campaign-masters/new" class="spark-btn-primary text-sm">
|
||||||
|
✨ New Campaign
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div class="spark-card p-4 border-red-500 text-red-400">
|
||||||
|
<strong>Error:</strong> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="grid grid-cols-4 gap-4">
|
||||||
|
<div class="spark-card p-6">
|
||||||
|
<div class="spark-label mb-2">Total Campaigns</div>
|
||||||
|
<div class="spark-data text-3xl">{stats.total}</div>
|
||||||
|
</div>
|
||||||
|
<div class="spark-card p-6">
|
||||||
|
<div class="spark-label mb-2">Active</div>
|
||||||
|
<div class="spark-data text-3xl text-green-400">{stats.active}</div>
|
||||||
|
</div>
|
||||||
|
<div class="spark-card p-6">
|
||||||
|
<div class="spark-label mb-2">Draft</div>
|
||||||
|
<div class="spark-data text-3xl text-yellow-400">{stats.draft}</div>
|
||||||
|
</div>
|
||||||
|
<div class="spark-card p-6">
|
||||||
|
<div class="spark-label mb-2">Completed</div>
|
||||||
|
<div class="spark-data text-3xl text-blue-400">{stats.completed}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Campaigns Grid -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{campaigns.map((campaign: any) => (
|
||||||
|
<div class="spark-card spark-card-hover p-6">
|
||||||
|
<div class="flex items-start justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-white font-semibold text-lg">{campaign.campaign_name || 'Unnamed Campaign'}</h3>
|
||||||
|
<p class="text-silver/70 text-sm mt-1">
|
||||||
|
{campaign.description?.substring(0, 100) || 'No description'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class={`px-3 py-1 rounded text-xs font-medium ${
|
||||||
|
campaign.status === 'active' ? 'bg-green-500/20 text-green-400 border border-green-500/30' :
|
||||||
|
campaign.status === 'draft' ? 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/30' :
|
||||||
|
campaign.status === 'completed' ? 'bg-blue-500/20 text-blue-400 border border-blue-500/30' :
|
||||||
|
'bg-graphite border border-edge-subtle text-silver'
|
||||||
|
}`}>
|
||||||
|
{campaign.status || 'draft'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2 text-sm mb-4">
|
||||||
|
{campaign.target_count && (
|
||||||
|
<div>
|
||||||
|
<span class="spark-label">Targets:</span>
|
||||||
|
<span class="text-silver ml-2">{campaign.target_count} items</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{campaign.articles_generated && (
|
||||||
|
<div>
|
||||||
|
<span class="spark-label">Generated:</span>
|
||||||
|
<span class="text-gold ml-2">{campaign.articles_generated} articles</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href={`/admin/collections/campaign-masters/${campaign.id}`} class="spark-btn-ghost text-xs px-3 py-1 flex-1 text-center">
|
||||||
|
Edit
|
||||||
|
</a>
|
||||||
|
<a href={`/admin/seo/articles?campaign=${campaign.id}`} class="spark-btn-secondary text-xs px-3 py-1 flex-1 text-center">
|
||||||
|
View Articles
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{campaigns.length === 0 && !error && (
|
||||||
|
<div class="col-span-full spark-card p-12 text-center">
|
||||||
|
<p class="text-silver/50">No campaigns found. Create your first campaign!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.addEventListener('export-data', async (e: any) => {
|
||||||
|
const { collection } = e.detail;
|
||||||
|
const response = await fetch(`/api/collections/${collection}/export`);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${collection}-${new Date().toISOString().split('T')[0]}.json`;
|
||||||
|
a.click();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
20
src/pages/admin/collections/cartesian-patterns.astro
Normal file
20
src/pages/admin/collections/cartesian-patterns.astro
Normal 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>{city}</code> or <code>{service}</code> to generate millions of unique combinations.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CartesianManager client:only="react" />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
20
src/pages/admin/collections/content-fragments.astro
Normal file
20
src/pages/admin/collections/content-fragments.astro
Normal 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>
|
||||||
195
src/pages/admin/collections/generation-jobs.astro
Normal file
195
src/pages/admin/collections/generation-jobs.astro
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* Generation Jobs Management
|
||||||
|
* Queue monitoring and job management for content_fragments collection
|
||||||
|
*/
|
||||||
|
import AdminLayout from '@/layouts/AdminLayout.astro';
|
||||||
|
import { getDirectusClient } from '@/lib/directus/client';
|
||||||
|
import { readItems } from '@directus/sdk';
|
||||||
|
|
||||||
|
const client = getDirectusClient();
|
||||||
|
|
||||||
|
let jobs = [];
|
||||||
|
let error = null;
|
||||||
|
let stats = {
|
||||||
|
total: 0,
|
||||||
|
pending: 0,
|
||||||
|
processing: 0,
|
||||||
|
completed: 0,
|
||||||
|
failed: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
jobs = await client.request(readItems('generation_jobs', {
|
||||||
|
fields: ['*'],
|
||||||
|
sort: ['-date_created'],
|
||||||
|
limit: 100,
|
||||||
|
}));
|
||||||
|
|
||||||
|
stats.total = jobs.length;
|
||||||
|
stats.pending = jobs.filter((j: any) => j.status === 'pending').length;
|
||||||
|
stats.processing = jobs.filter((j: any) => j.status === 'processing').length;
|
||||||
|
stats.completed = jobs.filter((j: any) => j.status === 'completed').length;
|
||||||
|
stats.failed = jobs.filter((j: any) => j.status === 'failed').length;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching jobs:', e);
|
||||||
|
error = e instanceof Error ? e.message : 'Unknown error';
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<AdminLayout title="Generation Jobs">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="spark-heading text-3xl">⚙️ Generation Jobs</h1>
|
||||||
|
<p class="text-silver mt-1">Content generation queue monitoring</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button class="spark-btn-secondary text-sm" onclick="location.reload()">
|
||||||
|
🔄 Refresh
|
||||||
|
</button>
|
||||||
|
<button class="spark-btn-secondary text-sm" onclick="window.dispatchEvent(new CustomEvent('export-data', {detail: {collection: 'generation_jobs'}}))">
|
||||||
|
📤 Export
|
||||||
|
</button>
|
||||||
|
<button class="spark-btn-primary text-sm" onclick="window.dispatchEvent(new CustomEvent('clear-completed'))">
|
||||||
|
🧹 Clear Completed
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div class="spark-card p-4 border-red-500 text-red-400">
|
||||||
|
<strong>Error:</strong> {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="grid grid-cols-5 gap-4">
|
||||||
|
<div class="spark-card p-6">
|
||||||
|
<div class="spark-label mb-2">Total Jobs</div>
|
||||||
|
<div class="spark-data text-3xl">{stats.total}</div>
|
||||||
|
</div>
|
||||||
|
<div class="spark-card p-6">
|
||||||
|
<div class="spark-label mb-2">Pending</div>
|
||||||
|
<div class="spark-data text-3xl text-yellow-400">{stats.pending}</div>
|
||||||
|
</div>
|
||||||
|
<div class="spark-card p-6">
|
||||||
|
<div class="spark-label mb-2">Processing</div>
|
||||||
|
<div class="spark-data text-3xl text-blue-400 animate-pulse">{stats.processing}</div>
|
||||||
|
</div>
|
||||||
|
<div class="spark-card p-6">
|
||||||
|
<div class="spark-label mb-2">Completed</div>
|
||||||
|
<div class="spark-data text-3xl text-green-400">{stats.completed}</div>
|
||||||
|
</div>
|
||||||
|
<div class="spark-card p-6">
|
||||||
|
<div class="spark-label mb-2">Failed</div>
|
||||||
|
<div class="spark-data text-3xl text-red-400">{stats.failed}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Jobs Table -->
|
||||||
|
<div class="spark-card overflow-hidden">
|
||||||
|
<div class="p-6 border-b border-edge-subtle">
|
||||||
|
<h2 class="text-white font-semibold">Recent Jobs</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-graphite">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left px-6 py-3 spark-label">Job ID</th>
|
||||||
|
<th class="text-left px-6 py-3 spark-label">Type</th>
|
||||||
|
<th class="text-left px-6 py-3 spark-label">Status</th>
|
||||||
|
<th class="text-left px-6 py-3 spark-label">Progress</th>
|
||||||
|
<th class="text-left px-6 py-3 spark-label">Created</th>
|
||||||
|
<th class="text-right px-6 py-3 spark-label">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{jobs.map((job: any, index: number) => (
|
||||||
|
<tr class={index % 2 === 0 ? 'bg-black/20' : ''}>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<code class="text-xs text-silver/70">{job.id.slice(0, 8)}...</code>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-white">{job.job_type || 'Article'}</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<span class={`px-2 py-1 rounded text-xs ${
|
||||||
|
job.status === 'completed' ? 'bg-green-500/20 text-green-400' :
|
||||||
|
job.status === 'processing' ? 'bg-blue-500/20 text-blue-400 animate-pulse' :
|
||||||
|
job.status === 'pending' ? 'bg-yellow-500/20 text-yellow-400' :
|
||||||
|
job.status === 'failed' ? 'bg-red-500/20 text-red-400' :
|
||||||
|
'bg-graphite text-silver'
|
||||||
|
}`}>
|
||||||
|
{job.status || 'pending'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="flex-1 bg-graphite rounded-full h-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class={`h-full ${
|
||||||
|
job.status === 'completed' ? 'bg-green-500' :
|
||||||
|
job.status === 'processing' ? 'bg-blue-500' :
|
||||||
|
job.status === 'failed' ? 'bg-red-500' :
|
||||||
|
'bg-yellow-500'
|
||||||
|
}`}
|
||||||
|
style={`width: ${job.progress || 0}%`}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-silver w-12 text-right">{job.progress || 0}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-silver text-sm">
|
||||||
|
{job.date_created ? new Date(job.date_created).toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}) : 'Unknown'}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-right">
|
||||||
|
{job.status === 'failed' && (
|
||||||
|
<button class="spark-btn-ghost text-xs px-3 py-1" onclick={`alert('Error: ${job.error_message || 'Unknown error'}')`}>
|
||||||
|
View Error
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{job.status === 'completed' && job.output_id && (
|
||||||
|
<a href={`/admin/seo/articles/${job.output_id}`} class="spark-btn-ghost text-xs px-3 py-1">
|
||||||
|
View Output
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{jobs.length === 0 && !error && (
|
||||||
|
<div class="p-12 text-center">
|
||||||
|
<p class="text-silver/50">No generation jobs found. Queue is empty!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.addEventListener('export-data', async (e: any) => {
|
||||||
|
const { collection } = e.detail;
|
||||||
|
const response = await fetch(`/api/collections/${collection}/export`);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${collection}-${new Date().toISOString().split('T')[0]}.json`;
|
||||||
|
a.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('clear-completed', async () => {
|
||||||
|
if (!confirm('Delete all completed jobs?')) return;
|
||||||
|
// TODO: API endpoint to delete completed jobs
|
||||||
|
alert('Feature coming soon!');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
21
src/pages/admin/collections/geo-intelligence.astro
Normal file
21
src/pages/admin/collections/geo-intelligence.astro
Normal 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>
|
||||||
21
src/pages/admin/collections/headline-inventory.astro
Normal file
21
src/pages/admin/collections/headline-inventory.astro
Normal 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>
|
||||||
21
src/pages/admin/collections/offer-blocks.astro
Normal file
21
src/pages/admin/collections/offer-blocks.astro
Normal 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>
|
||||||
21
src/pages/admin/collections/page-blocks.astro
Normal file
21
src/pages/admin/collections/page-blocks.astro
Normal 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>
|
||||||
20
src/pages/admin/collections/spintax-dictionaries.astro
Normal file
20
src/pages/admin/collections/spintax-dictionaries.astro
Normal 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>{key|word|variant}</code> syntax.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SpintaxManager client:only="react" />
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
17
src/pages/admin/content/avatars.astro
Normal file
17
src/pages/admin/content/avatars.astro
Normal 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>
|
||||||
0
src/pages/admin/content/pages.astro
Normal file
0
src/pages/admin/content/pages.astro
Normal file
0
src/pages/admin/content/posts.astro
Normal file
0
src/pages/admin/content/posts.astro
Normal file
14
src/pages/admin/content/work_log.astro
Normal file
14
src/pages/admin/content/work_log.astro
Normal 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>
|
||||||
9
src/pages/admin/factory.astro
Normal file
9
src/pages/admin/factory.astro
Normal 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>
|
||||||
222
src/pages/admin/factory/[id].astro
Normal file
222
src/pages/admin/factory/[id].astro
Normal 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>
|
||||||
63
src/pages/admin/factory/index.astro
Normal file
63
src/pages/admin/factory/index.astro
Normal 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>
|
||||||
19
src/pages/admin/factory/jobs.astro
Normal file
19
src/pages/admin/factory/jobs.astro
Normal 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>
|
||||||
33
src/pages/admin/factory/kanban.astro
Normal file
33
src/pages/admin/factory/kanban.astro
Normal 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>
|
||||||
15
src/pages/admin/index.astro
Normal file
15
src/pages/admin/index.astro
Normal 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>
|
||||||
0
src/pages/admin/intelligence/avatar-metrics.astro
Normal file
0
src/pages/admin/intelligence/avatar-metrics.astro
Normal file
107
src/pages/admin/intelligence/avatars.astro
Normal file
107
src/pages/admin/intelligence/avatars.astro
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Avatar Intelligence Management
|
||||||
|
* Full CRUD for avatar_intelligence collection
|
||||||
|
*/
|
||||||
|
|
||||||
|
---
|
||||||
|
import AdminLayout from '@/layouts/AdminLayout.astro';
|
||||||
|
import { getDirectusClient } from '@/lib/directus/client';
|
||||||
|
import { readItems } from '@directus/sdk';
|
||||||
|
|
||||||
|
const client = getDirectusClient();
|
||||||
|
|
||||||
|
let avatars = [];
|
||||||
|
let error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
avatars = await client.request(readItems('avatar_intelligence', {
|
||||||
|
fields: ['*'],
|
||||||
|
sort: ['base_name'],
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error fetching avatars:', e);
|
||||||
|
error = e instanceof Error ? e.message : 'Unknown error';
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<AdminLayout title="Avatar Intelligence">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="spark-heading text-3xl">Avatar Intelligence</h1>
|
||||||
|
<p class="text-silver mt-1">Manage persona profiles and variants</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button class="spark-btn-secondary text-sm">
|
||||||
|
📥 Import CSV
|
||||||
|
</button>
|
||||||
|
<button class="spark-btn-secondary text-sm">
|
||||||
|
📤 Export
|
||||||
|
</button>
|
||||||
|
<a href="/admin/intelligence/avatars/new" class="spark-btn-primary text-sm">
|
||||||
|
✨ New Avatar
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div class="spark-card p-4 border-red-500 text-red-400">
|
||||||
|
Error: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="grid grid-cols-4 gap-4">
|
||||||
|
<div class="spark-card p-6">
|
||||||
|
<div class="spark-label mb-2">Total Avatars</div>
|
||||||
|
<div class="spark-data text-3xl">{avatars.length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Avatars Grid -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{avatars.map((avatar: any) => (
|
||||||
|
<div class="spark-card spark-card-hover p-6">
|
||||||
|
<div class="flex items-start justify-between mb-4">
|
||||||
|
<h3 class="text-white font-semibold text-lg">{avatar.base_name}</h3>
|
||||||
|
<button class="spark-btn-ghost text-xs px-2 py-1">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="spark-label">Wealth Cluster:</span>
|
||||||
|
<span class="text-silver ml-2">{avatar.wealth_cluster || 'Not set'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{avatar.business_niches && (
|
||||||
|
<div>
|
||||||
|
<span class="spark-label">Niches:</span>
|
||||||
|
<div class="flex flex-wrap gap-1 mt-1">
|
||||||
|
{avatar.business_niches.slice(0, 3).map((niche: string) => (
|
||||||
|
<span class="px-2 py-0.5 bg-graphite border border-edge-subtle rounded text-xs text-silver">
|
||||||
|
{niche}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{avatar.business_niches.length > 3 && (
|
||||||
|
<span class="px-2 py-0.5 text-xs text-silver/50">
|
||||||
|
+{avatar.business_niches.length - 3} more
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{avatars.length === 0 && !error && (
|
||||||
|
<div class="col-span-full spark-card p-12 text-center">
|
||||||
|
<p class="text-silver/50">No avatars found. Create your first one!</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
21
src/pages/admin/intelligence/geo-targeting.astro
Normal file
21
src/pages/admin/intelligence/geo-targeting.astro
Normal 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>
|
||||||
80
src/pages/admin/intelligence/index.astro
Normal file
80
src/pages/admin/intelligence/index.astro
Normal 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>
|
||||||
0
src/pages/admin/intelligence/patterns.astro
Normal file
0
src/pages/admin/intelligence/patterns.astro
Normal file
19
src/pages/admin/intelligence/reports.astro
Normal file
19
src/pages/admin/intelligence/reports.astro
Normal 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>
|
||||||
0
src/pages/admin/leads/[id].astro
Normal file
0
src/pages/admin/leads/[id].astro
Normal file
19
src/pages/admin/leads/index.astro
Normal file
19
src/pages/admin/leads/index.astro
Normal 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>
|
||||||
8
src/pages/admin/locations.astro
Normal file
8
src/pages/admin/locations.astro
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
import AdminLayout from '../../layouts/AdminLayout.astro';
|
||||||
|
import LocationBrowser from '../../components/admin/LocationBrowser';
|
||||||
|
---
|
||||||
|
|
||||||
|
<AdminLayout title="Locations">
|
||||||
|
<LocationBrowser client:load />
|
||||||
|
</AdminLayout>
|
||||||
8
src/pages/admin/media/templates.astro
Normal file
8
src/pages/admin/media/templates.astro
Normal 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>
|
||||||
20
src/pages/admin/pages/[id].astro
Normal file
20
src/pages/admin/pages/[id].astro
Normal 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>
|
||||||
21
src/pages/admin/pages/index.astro
Normal file
21
src/pages/admin/pages/index.astro
Normal 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>
|
||||||
19
src/pages/admin/scheduler/index.astro
Normal file
19
src/pages/admin/scheduler/index.astro
Normal 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
Reference in New Issue
Block a user