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