Feature: Complete Admin UI Overhaul, Content Factory Showcase Mode, and Site Management
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardHeader, CardContent, CardTitle } from '@/components/ui/card'; // Check path/existence later, creating placeholder if needed
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; // Placeholder
|
||||
import JobLaunchpad from './JobLaunchpad';
|
||||
import LiveAssembler from './LiveAssembler';
|
||||
import ProductionFloor from './ProductionFloor';
|
||||
import SystemOverview from './SystemOverview';
|
||||
|
||||
export default function ContentFactoryDashboard() {
|
||||
const [activeTab, setActiveTab] = useState('launchpad');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Tabs defaultValue="launchpad" className="w-full" onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-4 max-w-2xl mb-4">
|
||||
<TabsTrigger value="launchpad">🚀 Job Launchpad</TabsTrigger>
|
||||
<TabsTrigger value="assembler">🛠️ Live Assembler</TabsTrigger>
|
||||
<TabsTrigger value="floor">🏭 Production Floor</TabsTrigger>
|
||||
<TabsTrigger value="docs">📚 System Overview</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="launchpad" className="space-y-4">
|
||||
<JobLaunchpad />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="assembler" className="space-y-4">
|
||||
<LiveAssembler />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="floor" className="space-y-4">
|
||||
<ProductionFloor />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="docs" className="space-y-4">
|
||||
<SystemOverview />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
213
frontend/src/components/admin/cartesian/JobLaunchpad.tsx
Normal file
213
frontend/src/components/admin/cartesian/JobLaunchpad.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardHeader, CardContent, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { getDirectusClient, readItems, createItem } from '@/lib/directus/client';
|
||||
|
||||
export default function JobLaunchpad() {
|
||||
const [sites, setSites] = useState<any[]>([]);
|
||||
const [avatars, setAvatars] = useState<any[]>([]);
|
||||
const [patterns, setPatterns] = useState<any[]>([]);
|
||||
|
||||
const [selectedSite, setSelectedSite] = useState('');
|
||||
const [selectedAvatars, setSelectedAvatars] = useState<string[]>([]);
|
||||
const [targetQuantity, setTargetQuantity] = useState(10);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [jobStatus, setJobStatus] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadData() {
|
||||
const client = getDirectusClient();
|
||||
try {
|
||||
const s = await client.request(readItems('sites'));
|
||||
const a = await client.request(readItems('avatars'));
|
||||
const p = await client.request(readItems('cartesian_patterns'));
|
||||
|
||||
setSites(s);
|
||||
setAvatars(a);
|
||||
setPatterns(p);
|
||||
} catch (e) {
|
||||
console.error("Failed to load data", e);
|
||||
}
|
||||
}
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const toggleAvatar = (id: string) => {
|
||||
if (selectedAvatars.includes(id)) {
|
||||
setSelectedAvatars(selectedAvatars.filter(x => x !== id));
|
||||
} else {
|
||||
setSelectedAvatars([...selectedAvatars, id]);
|
||||
}
|
||||
};
|
||||
|
||||
const calculatePermutations = () => {
|
||||
// Simple mock calculation
|
||||
// Real one would query preview-permutations API
|
||||
return selectedAvatars.length * 50 * 50 * (patterns.length || 1);
|
||||
};
|
||||
|
||||
const handleLaunch = async () => {
|
||||
if (!selectedSite || selectedAvatars.length === 0) return;
|
||||
setIsSubmitting(true);
|
||||
setJobStatus('Queuing...');
|
||||
|
||||
try {
|
||||
const client = getDirectusClient();
|
||||
// Create Job Record
|
||||
const job = await client.request(createItem('generation_jobs', {
|
||||
site_id: selectedSite,
|
||||
target_quantity: targetQuantity,
|
||||
status: 'Pending',
|
||||
filters: {
|
||||
avatars: selectedAvatars,
|
||||
patterns: patterns.map(p => p.id) // Use all patterns for now
|
||||
},
|
||||
current_offset: 0
|
||||
})); // Error: createItem not imported? client.request(createItem...)
|
||||
|
||||
// Trigger API (Fire and Forget or Wait)
|
||||
// We'll call the API to start processing immediately
|
||||
await fetch('/api/generate-content', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ jobId: job.id, batchSize: 5 })
|
||||
});
|
||||
|
||||
setJobStatus(`Job ${job.id} Started!`);
|
||||
} catch (e) {
|
||||
setJobStatus('Error launching job');
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Need to import createItem helper for client usage above?
|
||||
// No, I can use SDK function imported.
|
||||
// Client is already authenticated.
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>1. Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Target Site</label>
|
||||
<select
|
||||
className="w-full p-2 border rounded"
|
||||
value={selectedSite}
|
||||
onChange={e => setSelectedSite(e.target.value)}
|
||||
>
|
||||
<option value="">Select Site...</option>
|
||||
{sites.map(s => <option key={s.id} value={s.id}>{s.name || s.domain}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="bg-slate-50 p-4 rounded border border-slate-200">
|
||||
<label className="block text-sm font-bold mb-2">Launch Mode</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
className="accent-blue-600"
|
||||
checked={targetQuantity === 10 && selectedAvatars.length === avatars.length}
|
||||
onChange={() => {
|
||||
setTargetQuantity(10);
|
||||
setSelectedAvatars(avatars.map(a => a.id));
|
||||
}}
|
||||
/>
|
||||
<span>Full Site Setup (Home + Blog + 10 Posts)</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="mode"
|
||||
className="accent-blue-600"
|
||||
checked={targetQuantity !== 10 || selectedAvatars.length !== avatars.length}
|
||||
onChange={() => {
|
||||
setTargetQuantity(1);
|
||||
setSelectedAvatars([]);
|
||||
}}
|
||||
/>
|
||||
<span>Custom Batch</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="block text-sm font-medium">Select Avatars</label>
|
||||
<button
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
onClick={() => setSelectedAvatars(avatars.map(a => a.id))}
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{avatars.map(a => (
|
||||
<Badge
|
||||
key={a.id}
|
||||
variant={selectedAvatars.includes(a.id) ? 'default' : 'outline'}
|
||||
className="cursor-pointer"
|
||||
onClick={() => toggleAvatar(a.id)}
|
||||
>
|
||||
{a.base_name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4">
|
||||
<label className="block text-sm font-medium mb-1">Total Posts</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
className="w-full"
|
||||
value={targetQuantity}
|
||||
onChange={e => setTargetQuantity(parseInt(e.target.value))}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
className="w-20 p-2 border rounded text-center"
|
||||
value={targetQuantity}
|
||||
onChange={e => setTargetQuantity(parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-100 p-4 rounded text-center">
|
||||
<p className="text-sm text-slate-500">Estimated Permutations Available</p>
|
||||
<p className="text-3xl font-bold text-slate-800">{calculatePermutations().toLocaleString()}</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleLaunch}
|
||||
disabled={isSubmitting || !selectedSite || selectedAvatars.length === 0}
|
||||
className="w-full py-6 text-lg"
|
||||
>
|
||||
{isSubmitting ? 'Launching...' : '🚀 Launch Generation Job'}
|
||||
</Button>
|
||||
|
||||
{jobStatus && (
|
||||
<div className="p-2 text-center bg-blue-50 text-blue-700 rounded border border-blue-100">
|
||||
{jobStatus}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Need to import createItem locally if passing to client.request?
|
||||
// getDirectusClient return type allows chaining?
|
||||
// Using `client.request(createItem(...))` requires importing `createItem` from SDK.
|
||||
|
||||
119
frontend/src/components/admin/cartesian/LiveAssembler.tsx
Normal file
119
frontend/src/components/admin/cartesian/LiveAssembler.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardHeader, CardContent, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getDirectusClient, readItems } from '@/lib/directus/client';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export default function LiveAssembler() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<any>({ sites: [], avatars: [], cities: [], templates: [] });
|
||||
const [selections, setSelections] = useState({
|
||||
siteId: '',
|
||||
avatarId: '',
|
||||
cityId: '', // Need to fetch cities intelligently (too many), for now fetch first 100
|
||||
templateId: '',
|
||||
niche: ''
|
||||
});
|
||||
const [preview, setPreview] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
const client = getDirectusClient();
|
||||
const [sites, avatars, cities, templates] = await Promise.all([
|
||||
client.request(readItems('sites')),
|
||||
client.request(readItems('avatars')),
|
||||
client.request(readItems('geo_locations', { limit: 50 })), // Just sample
|
||||
client.request(readItems('article_templates'))
|
||||
]);
|
||||
setData({ sites, avatars, cities, templates });
|
||||
}
|
||||
init();
|
||||
}, []);
|
||||
|
||||
const handleGenerate = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/preview-article', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(selections)
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.error) throw new Error(json.error);
|
||||
setPreview(json);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 h-[calc(100vh-200px)]">
|
||||
{/* Controls */}
|
||||
<Card className="col-span-1 border-r h-full overflow-y-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>Assembler Settings</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="text-xs uppercase font-bold text-slate-500">Site</label>
|
||||
<select className="w-full p-2 border rounded mt-1"
|
||||
onChange={e => setSelections({ ...selections, siteId: e.target.value })}>
|
||||
<option value="">Select...</option>
|
||||
{data.sites.map((s: any) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs uppercase font-bold text-slate-500">Avatar</label>
|
||||
<select className="w-full p-2 border rounded mt-1"
|
||||
onChange={e => setSelections({ ...selections, avatarId: e.target.value })}>
|
||||
<option value="">Select...</option>
|
||||
{data.avatars.map((s: any) => <option key={s.id} value={s.id}>{s.base_name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs uppercase font-bold text-slate-500">City (Sample)</label>
|
||||
<select className="w-full p-2 border rounded mt-1"
|
||||
onChange={e => setSelections({ ...selections, cityId: e.target.value })}>
|
||||
<option value="">Select...</option>
|
||||
{data.cities.map((s: any) => <option key={s.id} value={s.id}>{s.city}, {s.state}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs uppercase font-bold text-slate-500">Template</label>
|
||||
<select className="w-full p-2 border rounded mt-1"
|
||||
onChange={e => setSelections({ ...selections, templateId: e.target.value })}>
|
||||
<option value="">Select...</option>
|
||||
{data.templates.map((s: any) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleGenerate} disabled={loading} className="w-full">
|
||||
{loading ? 'Assembling...' : 'Generate Preview'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Preview Window */}
|
||||
<div className="col-span-2 h-full overflow-y-auto bg-white border rounded-lg shadow-inner p-8">
|
||||
{preview ? (
|
||||
<div className="prose max-w-none">
|
||||
<div className="mb-6 pb-6 border-b">
|
||||
<h1 className="text-3xl font-bold text-slate-900 mb-2">{preview.title}</h1>
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="secondary">Slug: {preview.slug}</Badge>
|
||||
<Badge variant="outline">{preview.html_content.length} chars</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div dangerouslySetInnerHTML={{ __html: preview.html_content }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-slate-400">
|
||||
Configure setttings and click Generate to preview article.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
frontend/src/components/admin/cartesian/ProductionFloor.tsx
Normal file
90
frontend/src/components/admin/cartesian/ProductionFloor.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardHeader, CardContent, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { getDirectusClient, readItems } from '@/lib/directus/client';
|
||||
|
||||
export default function ProductionFloor() {
|
||||
const [jobs, setJobs] = useState<any[]>([]);
|
||||
|
||||
const fetchJobs = async () => {
|
||||
const client = getDirectusClient();
|
||||
try {
|
||||
const res = await client.request(readItems('generation_jobs', {
|
||||
sort: ['-date_created'],
|
||||
limit: 10
|
||||
}));
|
||||
setJobs(res);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchJobs();
|
||||
const interval = setInterval(fetchJobs, 5000); // Poll every 5s
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const getProgress = (job: any) => {
|
||||
if (!job.target_quantity) return 0;
|
||||
return Math.round((job.current_offset / job.target_quantity) * 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Active Job Queue</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<table className="min-w-full divide-y divide-slate-200">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">Job ID</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">Progress</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">Target</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-slate-200 text-sm">
|
||||
{jobs.map(job => (
|
||||
<tr key={job.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap font-mono text-xs">{job.id}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<Badge variant={job.status === 'Processing' ? 'default' : 'secondary'}>
|
||||
{job.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap w-1/3">
|
||||
<div className="w-full bg-slate-200 rounded-full h-2.5">
|
||||
<div
|
||||
className="bg-blue-600 h-2.5 rounded-full transition-all duration-500"
|
||||
style={{ width: `${getProgress(job)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 mt-1 block">{job.current_offset} / {job.target_quantity}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-slate-500">
|
||||
{job.target_quantity}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{jobs.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-12 text-center text-slate-400">
|
||||
No active jobs. Launch one from the pad!
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Could add Recent Articles feed here */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
frontend/src/components/admin/cartesian/SystemOverview.tsx
Normal file
114
frontend/src/components/admin/cartesian/SystemOverview.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardHeader, CardContent, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
|
||||
export default function SystemOverview() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Cartesian System Architecture</CardTitle>
|
||||
<CardDescription>
|
||||
This system generates high-volume, localized content by permuting
|
||||
[Avatars] x [Niches] x [Cities] x [Patterns].
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="schema" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-5">
|
||||
<TabsTrigger value="schema">1. Schema</TabsTrigger>
|
||||
<TabsTrigger value="engine">2. Engine</TabsTrigger>
|
||||
<TabsTrigger value="ui">3. UI</TabsTrigger>
|
||||
<TabsTrigger value="verification">4. Verification</TabsTrigger>
|
||||
<TabsTrigger value="publisher">5. Publisher</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="schema" className="p-4 border rounded-md mt-4 bg-slate-50">
|
||||
<h3 className="font-bold text-lg mb-2">9-Segment Data Architecture</h3>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
Data is stored in Directus across 9 linked collections. The script <code>init_schema.ts</code>
|
||||
handles the initial import.
|
||||
</p>
|
||||
<ul className="list-disc pl-5 space-y-1 text-sm">
|
||||
<li><strong>Avatars:</strong> Define the "Who" (e.g., Scaling Founder).</li>
|
||||
<li><strong>Niches:</strong> Define the "What" (e.g., Vertical SaaS).</li>
|
||||
<li><strong>Locations:</strong> Define the "Where" (Clusters of cities).</li>
|
||||
<li><strong>Patterns:</strong> Logic templates for generating titles/hooks.</li>
|
||||
</ul>
|
||||
<div className="mt-4 bg-slate-900 text-slate-50 p-3 rounded text-xs font-mono">
|
||||
{`// Example Data Relation
|
||||
Avatar: "Scaling Founder"
|
||||
-> Variant: { pronoun: "he", wealth: "high" }
|
||||
-> Niche: "SaaS"
|
||||
City: "Austin, TX"
|
||||
-> Cluster: "Silicon Hills"
|
||||
`}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="engine" className="p-4 border rounded-md mt-4 bg-slate-50">
|
||||
<h3 className="font-bold text-lg mb-2">The Cartesian Engine</h3>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
Located in <code>lib/cartesian</code>. It processes the combinations.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm">SpintaxParser</h4>
|
||||
<p className="text-xs text-slate-500">Recursive selection.</p>
|
||||
<code className="block bg-slate-200 p-2 rounded text-xs mt-1">
|
||||
{`{Hi|Hello {World|Friend}} => "Hello Friend"`}
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-sm">GrammarEngine</h4>
|
||||
<p className="text-xs text-slate-500">Token resolution.</p>
|
||||
<code className="block bg-slate-200 p-2 rounded text-xs mt-1">
|
||||
{`[[PRONOUN]] is [[A_AN:Apple]] => "He is an Apple"`}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="ui" className="p-4 border rounded-md mt-4 bg-slate-50">
|
||||
<h3 className="font-bold text-lg mb-2">Command Station (UI)</h3>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
Three core tools for managing production:
|
||||
</p>
|
||||
<ul className="money-list space-y-2 text-sm">
|
||||
<li>🚀 <strong>Job Launchpad:</strong> Configure batches. Select Site + Avatars.</li>
|
||||
<li>🛠️ <strong>Live Assembler:</strong> Preview generation logic in real-time.</li>
|
||||
<li>🏭 <strong>Production Floor:</strong> Monitor active jobs and progress.</li>
|
||||
</ul>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="verification" className="p-4 border rounded-md mt-4 bg-slate-50">
|
||||
<h3 className="font-bold text-lg mb-2">Verification Protocols</h3>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
We run automated scripts to ensure logic integrity before deployment.
|
||||
</p>
|
||||
<div className="bg-green-50 text-green-800 p-3 rounded border border-green-200 text-xs font-mono">
|
||||
{`✅ Spintax Resolved
|
||||
✅ Grammar Resolved
|
||||
✅ Logic Verification Passed.`}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="publisher" className="p-4 border rounded-md mt-4 bg-slate-50">
|
||||
<h3 className="font-bold text-lg mb-2">Publisher Service</h3>
|
||||
<p className="text-sm text-slate-600 mb-4">
|
||||
Handles the "Last Mile" delivery. Pushes generated content from Directus to:
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
<Badge variant="outline">WordPress (REST API)</Badge>
|
||||
<Badge variant="outline">Webflow</Badge>
|
||||
<Badge variant="outline">Static HTML</Badge>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
frontend/src/components/admin/leads/LeadList.tsx
Normal file
65
frontend/src/components/admin/leads/LeadList.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getDirectusClient, readItems } from '@/lib/directus/client';
|
||||
import { Table } from '@/components/ui/table';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export default function LeadList() {
|
||||
const [leads, setLeads] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const client = getDirectusClient();
|
||||
// @ts-ignore
|
||||
const data = await client.request(readItems('leads', { sort: ['-date_created'] }));
|
||||
setLeads(data);
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-left text-sm text-slate-400">
|
||||
<thead className="bg-slate-900/50 text-slate-200 uppercase font-medium">
|
||||
<tr>
|
||||
<th className="px-6 py-3">Name</th>
|
||||
<th className="px-6 py-3">Email</th>
|
||||
<th className="px-6 py-3">Source</th>
|
||||
<th className="px-6 py-3">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-700">
|
||||
{leads.map(lead => (
|
||||
<tr key={lead.id} className="hover:bg-slate-700/50 transition-colors">
|
||||
<td className="px-6 py-4 font-medium text-slate-200">
|
||||
{lead.full_name || 'Anonymous'}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{lead.email}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Badge variant="outline">{lead.source || 'Direct'}</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{new Date(lead.date_created).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{leads.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-12 text-center text-slate-500">
|
||||
No leads found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
frontend/src/components/admin/pages/PageEditor.tsx
Normal file
43
frontend/src/components/admin/pages/PageEditor.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
// @ts-nocheck
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getDirectusClient, readItem, updateItem } from '@/lib/directus/client';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export default function PageEditor({ id }: { id: string }) {
|
||||
const [page, setPage] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const client = getDirectusClient();
|
||||
try {
|
||||
const data = await client.request(readItem('pages', id));
|
||||
setPage(data);
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
if (id) load();
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (!page) return <div>Page not found</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<Card className="bg-slate-800 border-slate-700 p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Page Title</Label>
|
||||
<Input value={page.title} className="bg-slate-900 border-slate-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Permalink</Label>
|
||||
<Input value={page.permalink} className="bg-slate-900 border-slate-700" />
|
||||
</div>
|
||||
<Button className="mt-4">Save Changes</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
frontend/src/components/admin/pages/PageList.tsx
Normal file
50
frontend/src/components/admin/pages/PageList.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getDirectusClient, readItems } from '@/lib/directus/client';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Page } from '@/types/schema'; // Ensure exported
|
||||
|
||||
export default function PageList() {
|
||||
const [pages, setPages] = useState<Page[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const client = getDirectusClient();
|
||||
// @ts-ignore
|
||||
const data = await client.request(readItems('pages', { fields: ['*', 'site.name'] }));
|
||||
setPages(data as unknown as Page[]);
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{pages.map(page => (
|
||||
<Card key={page.id} className="bg-slate-800 border-slate-700 hover:bg-slate-800/80 transition-colors cursor-pointer">
|
||||
<CardHeader className="p-4 flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg font-medium text-slate-200">{page.title}</CardTitle>
|
||||
<div className="text-sm text-slate-500 font-mono mt-1">/{page.permalink}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="text-slate-400 border-slate-600">
|
||||
{/* @ts-ignore */}
|
||||
{page.site?.name || 'Unknown Site'}
|
||||
</Badge>
|
||||
<Badge variant={page.status === 'published' ? 'default' : 'secondary'}>
|
||||
{page.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
{pages.length === 0 && <div className="text-center text-slate-500 py-10">No pages found.</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
frontend/src/components/admin/posts/PostEditor.tsx
Normal file
47
frontend/src/components/admin/posts/PostEditor.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
// @ts-nocheck
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getDirectusClient, readItem } from '@/lib/directus/client';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export default function PostEditor({ id }: { id: string }) {
|
||||
const [post, setPost] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const client = getDirectusClient();
|
||||
try {
|
||||
const data = await client.request(readItem('posts', id));
|
||||
setPost(data);
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
if (id) load();
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (!post) return <div>Post not found</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<Card className="bg-slate-800 border-slate-700 p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Post Title</Label>
|
||||
<Input value={post.title} className="bg-slate-900 border-slate-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Slug</Label>
|
||||
<Input value={post.slug} className="bg-slate-900 border-slate-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Content (Markdown/HTML)</Label>
|
||||
<textarea className="w-full bg-slate-900 border-slate-700 rounded p-3 min-h-[300px]" value={post.content || ''}></textarea>
|
||||
</div>
|
||||
<Button className="mt-4">Save Changes</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
frontend/src/components/admin/posts/PostList.tsx
Normal file
71
frontend/src/components/admin/posts/PostList.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getDirectusClient, readItems } from '@/lib/directus/client';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; // Need to implement Table? Or use grid.
|
||||
// Assume Table isn't fully ready or use Grid for now to be safe.
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Post } from '@/types/schema';
|
||||
|
||||
export default function PostList() {
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const client = getDirectusClient();
|
||||
// @ts-ignore
|
||||
const data = await client.request(readItems('posts', { fields: ['*', 'site.name', 'author.name'], limit: 50 }));
|
||||
setPosts(data as unknown as Post[]);
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-left text-sm text-slate-400">
|
||||
<thead className="bg-slate-900/50 text-slate-200 uppercase font-medium">
|
||||
<tr>
|
||||
<th className="px-6 py-3">Title</th>
|
||||
<th className="px-6 py-3">Site</th>
|
||||
<th className="px-6 py-3">Status</th>
|
||||
<th className="px-6 py-3">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-700">
|
||||
{posts.map(post => (
|
||||
<tr key={post.id} className="hover:bg-slate-700/50 cursor-pointer transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium text-slate-200">{post.title}</div>
|
||||
<div className="text-xs text-slate-500">{post.slug}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{/* @ts-ignore */}
|
||||
{post.site?.name || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<Badge variant={post.status === 'published' ? 'default' : 'secondary'}>
|
||||
{post.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{new Date(post.date_created || '').toLocaleDateString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{posts.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-12 text-center text-slate-500">
|
||||
No posts found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
frontend/src/components/admin/seo/ArticleEditor.tsx
Normal file
147
frontend/src/components/admin/seo/ArticleEditor.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
// @ts-nocheck
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getDirectusClient, readItem, updateItem } from '@/lib/directus/client';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
// Simple rich text area placeholder
|
||||
const TextArea = (props: any) => <textarea {...props} className="w-full min-h-[400px] p-4 bg-slate-900 border-slate-700 rounded-lg text-slate-300 font-mono text-sm leading-relaxed" />;
|
||||
|
||||
export default function ArticleEditor({ id }: { id: string }) {
|
||||
const [article, setArticle] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const client = getDirectusClient();
|
||||
const a = await client.request(readItem('generated_articles', id));
|
||||
setArticle(a);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
if (id) load();
|
||||
}, [id]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!article) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const client = getDirectusClient();
|
||||
await client.request(updateItem('generated_articles', id, {
|
||||
title: article.title,
|
||||
slug: article.slug,
|
||||
html_content: article.html_content,
|
||||
meta_desc: article.meta_desc,
|
||||
is_published: article.is_published
|
||||
}));
|
||||
alert("Article Saved!");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Failed to save.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div>Loading article data...</div>;
|
||||
if (!article) return <div>Article not found</div>;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Main Content Column */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardHeader>
|
||||
<CardTitle>Content Editor</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Title</Label>
|
||||
<Input
|
||||
value={article.title}
|
||||
onChange={e => setArticle({ ...article, title: e.target.value })}
|
||||
className="bg-slate-900 border-slate-700 font-bold text-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>HTML Content</Label>
|
||||
<TextArea
|
||||
value={article.html_content || ''}
|
||||
onChange={e => setArticle({ ...article, html_content: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar Column */}
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Status & Meta */}
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardHeader>
|
||||
<CardTitle>Publishing</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-between items-center bg-slate-900 p-3 rounded">
|
||||
<span className="text-sm text-slate-400">Status</span>
|
||||
<Badge variant={article.is_published ? 'default' : 'secondary'}>
|
||||
{article.is_published ? 'Published' : 'Draft'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Slug</Label>
|
||||
<Input
|
||||
value={article.slug}
|
||||
onChange={e => setArticle({ ...article, slug: e.target.value })}
|
||||
className="bg-slate-900 border-slate-700 text-xs font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Meta Description</Label>
|
||||
<textarea
|
||||
className="w-full text-xs p-2 bg-slate-900 border border-slate-700 rounded h-24"
|
||||
value={article.meta_desc || ''}
|
||||
onChange={e => setArticle({ ...article, meta_desc: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} disabled={saving} className="w-full bg-blue-600 hover:bg-blue-700">
|
||||
{saving ? 'Saving...' : 'Update Article'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Generation Metadata (ReadOnly) */}
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm text-slate-400">Generation Data</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-xs font-mono">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Job ID</span>
|
||||
<span className="text-slate-300">{article.job_id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-500">Created</span>
|
||||
<span className="text-slate-300">{new Date(article.date_created).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
212
frontend/src/components/admin/sites/SiteEditor.tsx
Normal file
212
frontend/src/components/admin/sites/SiteEditor.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getDirectusClient, readItem, updateItem } from '@/lib/directus/client';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Site } from '@/types/schema';
|
||||
|
||||
interface SiteEditorProps {
|
||||
id: string; // Astro passes string params
|
||||
}
|
||||
|
||||
export default function SiteEditor({ id }: SiteEditorProps) {
|
||||
const [site, setSite] = useState<Site | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Feature Flags State (mapped to settings)
|
||||
const [features, setFeatures] = useState({
|
||||
maintenance_mode: false,
|
||||
seo_indexing: true,
|
||||
https_enforced: true,
|
||||
analytics_enabled: false,
|
||||
blog_enabled: true,
|
||||
leads_capture: true
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const client = getDirectusClient();
|
||||
// @ts-ignore
|
||||
const s = await client.request(readItem('sites', id));
|
||||
setSite(s as Site);
|
||||
|
||||
// Merge settings into defaults
|
||||
if (s.settings) {
|
||||
setFeatures(prev => ({ ...prev, ...s.settings }));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
if (id) load();
|
||||
}, [id]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!site) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const client = getDirectusClient();
|
||||
// @ts-ignore
|
||||
await client.request(updateItem('sites', id, {
|
||||
// Update basic fields if changed (add logic later)
|
||||
status: site.status,
|
||||
settings: features
|
||||
}));
|
||||
// Show toast?
|
||||
alert("Site Settings Saved!");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("Error saving site.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (!site) return <div>Site not found</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
{/* Header / Meta */}
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardHeader>
|
||||
<CardTitle>General Information</CardTitle>
|
||||
<CardDescription>Basic site identity and connectivity.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Site Name</Label>
|
||||
<Input
|
||||
value={site.name}
|
||||
disabled
|
||||
className="bg-slate-900 border-slate-700"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Domain</Label>
|
||||
<Input
|
||||
value={site.domain}
|
||||
disabled
|
||||
className="bg-slate-900 border-slate-700 font-mono text-blue-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Feature Toggles (CheckBox Options) */}
|
||||
<Card className="bg-slate-800 border-slate-700">
|
||||
<CardHeader>
|
||||
<CardTitle>Feature Configuration</CardTitle>
|
||||
<CardDescription>Enable or disable specific modules and behaviors for this site.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
{/* Maintenance Mode */}
|
||||
<div className="flex items-center justify-between space-x-2 border p-4 rounded-lg border-slate-700 bg-slate-900/50">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base font-medium">Maintenance Mode</Label>
|
||||
<p className="text-sm text-slate-500">
|
||||
Show a "Coming Soon" page to all visitors.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={features.maintenance_mode}
|
||||
onCheckedChange={(c) => setFeatures({ ...features, maintenance_mode: c })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SEO Indexing */}
|
||||
<div className="flex items-center justify-between space-x-2 border p-4 rounded-lg border-slate-700 bg-slate-900/50">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base font-medium">Search Indexing</Label>
|
||||
<p className="text-sm text-slate-500">
|
||||
Allow Google/Bing to index this site.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={features.seo_indexing}
|
||||
onCheckedChange={(c) => setFeatures({ ...features, seo_indexing: c })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* HTTPS Enforced */}
|
||||
<div className="flex items-center justify-between space-x-2 border p-4 rounded-lg border-slate-700 bg-slate-900/50">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base font-medium">Enforce HTTPS</Label>
|
||||
<p className="text-sm text-slate-500">
|
||||
Redirect all HTTP traffic to HTTPS.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={features.https_enforced}
|
||||
onCheckedChange={(c) => setFeatures({ ...features, https_enforced: c })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Analytics */}
|
||||
<div className="flex items-center justify-between space-x-2 border p-4 rounded-lg border-slate-700 bg-slate-900/50">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base font-medium">Analytics</Label>
|
||||
<p className="text-sm text-slate-500">
|
||||
Inject GTM/GA4 scripts.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={features.analytics_enabled}
|
||||
onCheckedChange={(c) => setFeatures({ ...features, analytics_enabled: c })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Blog */}
|
||||
<div className="flex items-center justify-between space-x-2 border p-4 rounded-lg border-slate-700 bg-slate-900/50">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base font-medium">Blog System</Label>
|
||||
<p className="text-sm text-slate-500">
|
||||
Enable generated posts and archive pages.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={features.blog_enabled}
|
||||
onCheckedChange={(c) => setFeatures({ ...features, blog_enabled: c })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Leads */}
|
||||
<div className="flex items-center justify-between space-x-2 border p-4 rounded-lg border-slate-700 bg-slate-900/50">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base font-medium">Lead Capture</Label>
|
||||
<p className="text-sm text-slate-500">
|
||||
Process form submissions and webhooks.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={features.leads_capture}
|
||||
onCheckedChange={(c) => setFeatures({ ...features, leads_capture: c })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button variant="outline" onClick={() => window.history.back()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={saving} className="bg-green-600 hover:bg-green-700">
|
||||
{saving ? 'Saving...' : 'Save Configuration'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
frontend/src/components/admin/sites/SiteList.tsx
Normal file
64
frontend/src/components/admin/sites/SiteList.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getDirectusClient, readItems } from '@/lib/directus/client';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Site } from '@/types/schema';
|
||||
|
||||
export default function SiteList() {
|
||||
const [sites, setSites] = useState<Site[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const client = getDirectusClient();
|
||||
// @ts-ignore
|
||||
const s = await client.request(readItems('sites'));
|
||||
setSites(s as Site[]);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
load();
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="text-slate-400">Loading sites...</div>;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{sites.map(site => (
|
||||
<Card key={site.id} className="bg-slate-800 border-slate-700 hover:border-slate-600 transition-all cursor-pointer group" onClick={() => window.location.href = `/admin/sites/${site.id}`}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-slate-200">
|
||||
{site.name}
|
||||
</CardTitle>
|
||||
<Badge variant={site.status === 'active' ? 'default' : 'secondary'}>
|
||||
{site.status}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-white mb-2">{site.domain}</div>
|
||||
<p className="text-xs text-slate-500">
|
||||
{site.settings?.template || 'Default Template'}
|
||||
</p>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
Configure
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Empty State / Add New Placeholder */}
|
||||
{sites.length === 0 && (
|
||||
<div className="col-span-full text-center py-12 bg-slate-800/50 rounded-xl border border-dashed border-slate-700">
|
||||
<p className="text-slate-400">No sites found.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
frontend/src/env.d.ts
vendored
1
frontend/src/env.d.ts
vendored
@@ -1,3 +1,4 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
/// <reference types="astro/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
|
||||
@@ -6,18 +6,39 @@ interface Props {
|
||||
const { title } = Astro.props;
|
||||
const currentPath = Astro.url.pathname;
|
||||
|
||||
const navItems = [
|
||||
{ href: '/admin', label: 'Dashboard', icon: 'home' },
|
||||
{ href: '/admin/pages', label: 'Pages', icon: 'file' },
|
||||
{ href: '/admin/posts', label: 'Posts', icon: 'edit' },
|
||||
{ href: '/admin/seo/campaigns', label: 'SEO Campaigns', icon: 'target' },
|
||||
{ href: '/admin/seo/articles', label: 'Generated Articles', icon: 'newspaper' },
|
||||
{ href: '/admin/seo/fragments', label: 'Content Fragments', icon: 'puzzle' },
|
||||
{ href: '/admin/seo/headlines', label: 'Headlines', icon: 'heading' },
|
||||
{ href: '/admin/media/templates', label: 'Image Templates', icon: 'image' },
|
||||
{ href: '/admin/locations', label: 'Locations', icon: 'map' },
|
||||
{ href: '/admin/leads', label: 'Leads', icon: 'users' },
|
||||
{ href: '/admin/settings', label: 'Settings', icon: 'settings' },
|
||||
const navGroups = [
|
||||
{
|
||||
title: 'Command Center',
|
||||
items: [
|
||||
{ href: '/admin', label: 'Dashboard', icon: 'home' },
|
||||
{ href: '/admin/content-factory', label: 'Content Factory', icon: 'zap' },
|
||||
{ href: '/admin/seo/articles', label: 'Generated Output', icon: 'newspaper' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Site Management',
|
||||
items: [
|
||||
{ href: '/admin/sites', label: 'Sites', icon: 'map' },
|
||||
{ href: '/admin/pages', label: 'Pages', icon: 'file' },
|
||||
{ href: '/admin/posts', label: 'Posts', icon: 'edit' },
|
||||
{ href: '/admin/leads', label: 'Leads', icon: 'users' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'SEO Assets',
|
||||
items: [
|
||||
{ href: '/admin/locations', label: 'Locations', icon: 'map' },
|
||||
{ href: '/admin/seo/fragments', label: 'Fragments', icon: 'puzzle' },
|
||||
{ href: '/admin/seo/headlines', label: 'Headlines', icon: 'heading' },
|
||||
{ href: '/admin/media/templates', label: 'Templates', icon: 'image' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
items: [
|
||||
{ href: '/admin/settings', label: 'Settings', icon: 'settings' },
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
function isActive(href: string) {
|
||||
@@ -74,8 +95,8 @@ function isActive(href: string) {
|
||||
|
||||
<body class="min-h-screen flex antialiased">
|
||||
<!-- Sidebar -->
|
||||
<aside class="w-64 bg-gray-900 border-r border-gray-800 flex flex-col fixed h-full">
|
||||
<div class="p-6 border-b border-gray-800">
|
||||
<aside class="w-64 bg-gray-900 border-r border-gray-800 flex flex-col fixed h-full overflow-y-auto">
|
||||
<div class="p-6 border-b border-gray-800 sticky top-0 bg-gray-900 z-10">
|
||||
<a href="/admin" class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -86,76 +107,90 @@ function isActive(href: string) {
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<nav class="flex-1 p-4 space-y-1 overflow-y-auto">
|
||||
{navItems.map((item) => (
|
||||
<a
|
||||
href={item.href}
|
||||
class={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
|
||||
isActive(item.href)
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span class="w-5 h-5">
|
||||
{item.icon === 'home' && (
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
)}
|
||||
{item.icon === 'file' && (
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
)}
|
||||
{item.icon === 'edit' && (
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
)}
|
||||
{item.icon === 'target' && (
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
)}
|
||||
{item.icon === 'newspaper' && (
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
|
||||
</svg>
|
||||
)}
|
||||
{item.icon === 'puzzle' && (
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
|
||||
</svg>
|
||||
)}
|
||||
{item.icon === 'heading' && (
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7" />
|
||||
</svg>
|
||||
)}
|
||||
{item.icon === 'image' && (
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
{item.icon === 'map' && (
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
)}
|
||||
{item.icon === 'users' && (
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
)}
|
||||
{item.icon === 'settings' && (
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span class="font-medium">{item.label}</span>
|
||||
</a>
|
||||
<nav class="flex-1 p-4 space-y-8">
|
||||
{navGroups.map((group) => (
|
||||
<div>
|
||||
<h3 class="px-4 text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
|
||||
{group.title}
|
||||
</h3>
|
||||
<div class="space-y-1">
|
||||
{group.items.map((item) => (
|
||||
<a
|
||||
href={item.href}
|
||||
class={`flex items-center gap-3 px-4 py-2 rounded-lg transition-colors text-sm ${
|
||||
isActive(item.href)
|
||||
? 'bg-primary/20 text-primary font-medium'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span class="w-5 h-5 flex-shrink-0">
|
||||
{item.icon === 'home' && (
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
)}
|
||||
{item.icon === 'zap' && (
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
)}
|
||||
{item.icon === 'file' && (
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
)}
|
||||
{item.icon === 'edit' && (
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
)}
|
||||
{item.icon === 'target' && (
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
)}
|
||||
{item.icon === 'newspaper' && (
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
|
||||
</svg>
|
||||
)}
|
||||
{item.icon === 'puzzle' && (
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
|
||||
</svg>
|
||||
)}
|
||||
{item.icon === 'heading' && (
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7" />
|
||||
</svg>
|
||||
)}
|
||||
{item.icon === 'image' && (
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
{item.icon === 'map' && (
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||
</svg>
|
||||
)}
|
||||
{item.icon === 'users' && (
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||
</svg>
|
||||
)}
|
||||
{item.icon === 'settings' && (
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
<span class="font-medium">{item.label}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
|
||||
223
frontend/src/lib/cartesian/CartesianEngine.ts
Normal file
223
frontend/src/lib/cartesian/CartesianEngine.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import { SpintaxParser } from './SpintaxParser';
|
||||
import { GrammarEngine } from './GrammarEngine';
|
||||
import { HTMLRenderer } from './HTMLRenderer';
|
||||
import { createDirectus, rest, staticToken, readItems, readItem } from '@directus/sdk';
|
||||
|
||||
// Config
|
||||
// In a real app, client should be passed in or singleton
|
||||
// For this class, we assume data is passed in or we have a method to fetch it.
|
||||
|
||||
export interface GenerationContext {
|
||||
avatar: any;
|
||||
niche: string;
|
||||
city: any;
|
||||
site: any;
|
||||
template: any;
|
||||
}
|
||||
|
||||
export class CartesianEngine {
|
||||
private client: any;
|
||||
|
||||
constructor(directusClient: any) {
|
||||
this.client = directusClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a single article based on specific inputs.
|
||||
*/
|
||||
async generateArticle(context: GenerationContext) {
|
||||
const { avatar, niche, city, site, template } = context;
|
||||
const variant = await this.getAvatarVariant(avatar.id, 'neutral'); // Default to neutral or specific
|
||||
|
||||
// 1. Process Template Blocks
|
||||
const blocksData = [];
|
||||
|
||||
// Parse structure_json (assuming array of block IDs)
|
||||
const blockIds = Array.isArray(template.structure_json) ? template.structure_json : [];
|
||||
|
||||
for (const blockId of blockIds) {
|
||||
// Fetch Universal Block
|
||||
// In production, fetch specific fields to optimize
|
||||
let universal: any = {};
|
||||
try {
|
||||
// Assuming blockId is the ID in offer_blocks_universal (or key)
|
||||
// Since we stored them as items, we query by block_id field or id
|
||||
const result = await this.client.request(readItems('offer_blocks_universal' as any, {
|
||||
filter: { block_id: { _eq: blockId } },
|
||||
limit: 1
|
||||
}));
|
||||
universal = result[0] || {};
|
||||
} catch (e) { console.error(`Block not found: ${blockId}`); }
|
||||
|
||||
// Fetch Personalized Expansion
|
||||
let personal: any = {};
|
||||
try {
|
||||
// Need a way to match block_id + avatar_id.
|
||||
// Our schema imported flat structure?
|
||||
// Ideally we query the offer_blocks_personalized collection
|
||||
// filtering by block_related_id AND avatar_related_id
|
||||
// For prototype, we might have stored it loosely.
|
||||
// Let's assume we can fetch.
|
||||
} catch (e) { }
|
||||
|
||||
// MERGE (Simplified for now - using universal only + placeholder)
|
||||
// Real merge adds personal pains to universal pains
|
||||
const mergedBlock = {
|
||||
id: blockId,
|
||||
title: universal.title,
|
||||
hook: universal.hook_generator,
|
||||
pains: universal.universal_pains || [],
|
||||
solutions: universal.universal_solutions || [],
|
||||
value_points: universal.universal_value_points || [],
|
||||
cta: universal.cta_spintax,
|
||||
spintax: universal.spintax_content // Assuming a new field for full block spintax
|
||||
};
|
||||
|
||||
// 2. Resolve Tokens Per Block
|
||||
const solvedBlock = this.resolveBlock(mergedBlock, context, variant);
|
||||
blocksData.push(solvedBlock);
|
||||
}
|
||||
|
||||
// 3. Assemble HTML
|
||||
const html = HTMLRenderer.renderArticle(blocksData);
|
||||
|
||||
// 4. Generate Meta
|
||||
const metaTitle = this.generateMetaTitle(context, variant);
|
||||
|
||||
return {
|
||||
title: metaTitle,
|
||||
html_content: html,
|
||||
slug: this.generateSlug(metaTitle),
|
||||
meta_desc: "Generated description..." // Implementation TBD
|
||||
};
|
||||
}
|
||||
|
||||
private resolveBlock(block: any, ctx: GenerationContext, variant: any): any {
|
||||
const resolve = (text: string) => {
|
||||
if (!text) return '';
|
||||
let t = text;
|
||||
|
||||
// Level 1: Variables
|
||||
t = t.replace(/{{NICHE}}/g, ctx.niche || 'Business');
|
||||
t = t.replace(/{{CITY}}/g, ctx.city.city);
|
||||
t = t.replace(/{{STATE}}/g, ctx.city.state);
|
||||
t = t.replace(/{{ZIP_FOCUS}}/g, ctx.city.zip_focus || '');
|
||||
t = t.replace(/{{AGENCY_NAME}}/g, "Spark Agency"); // Config
|
||||
t = t.replace(/{{AGENCY_URL}}/g, ctx.site.url);
|
||||
|
||||
// Level 2: Spintax
|
||||
t = SpintaxParser.parse(t);
|
||||
|
||||
// Level 3: Grammar
|
||||
t = GrammarEngine.resolve(t, variant);
|
||||
|
||||
return t;
|
||||
};
|
||||
|
||||
const resolvedBlock: any = {
|
||||
id: block.id,
|
||||
title: resolve(block.title),
|
||||
hook: resolve(block.hook),
|
||||
pains: (block.pains || []).map(resolve),
|
||||
solutions: (block.solutions || []).map(resolve),
|
||||
value_points: (block.value_points || []).map(resolve),
|
||||
cta: resolve(block.cta)
|
||||
};
|
||||
|
||||
// Handle Spintax Content & Components
|
||||
if (block.spintax) {
|
||||
let content = SpintaxParser.parse(block.spintax);
|
||||
|
||||
// Dynamic Component Replacement
|
||||
if (content.includes('{{COMPONENT_AVATAR_GRID}}')) {
|
||||
content = content.replace('{{COMPONENT_AVATAR_GRID}}', this.generateAvatarGrid());
|
||||
}
|
||||
if (content.includes('{{COMPONENT_OPTIN_FORM}}')) {
|
||||
content = content.replace('{{COMPONENT_OPTIN_FORM}}', this.generateOptinForm());
|
||||
}
|
||||
|
||||
content = GrammarEngine.resolve(content, variant);
|
||||
resolvedBlock.content = content;
|
||||
}
|
||||
|
||||
return resolvedBlock;
|
||||
}
|
||||
|
||||
private generateAvatarGrid(): string {
|
||||
const avatars = [
|
||||
"Scaling Founder", "Marketing Director", "Ecom Owner", "SaaS CEO", "Local Biz Owner",
|
||||
"Real Estate Agent", "Coach/Consultant", "Agency Owner", "Startup CTO", "Enterprise VP"
|
||||
];
|
||||
|
||||
let html = '<div class="grid grid-cols-2 md:grid-cols-5 gap-4 my-8">';
|
||||
avatars.forEach(a => {
|
||||
html += `
|
||||
<div class="p-4 border border-slate-700 rounded-lg text-center bg-slate-800">
|
||||
<div class="w-12 h-12 bg-blue-600/20 rounded-full mx-auto mb-2 flex items-center justify-center text-blue-400 font-bold">
|
||||
${a[0]}
|
||||
</div>
|
||||
<div class="text-xs font-medium text-white">${a}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
private generateOptinForm(): string {
|
||||
return `
|
||||
<div class="bg-blue-900/20 border border-blue-800 p-8 rounded-xl my-8 text-center">
|
||||
<h3 class="text-2xl font-bold text-white mb-4">Book Your Strategy Session</h3>
|
||||
<p class="text-slate-400 mb-6">Stop guessing. Get a custom roadmap consisting of the exact systems we used to scale.</p>
|
||||
<form class="max-w-md mx-auto space-y-4">
|
||||
<input type="email" placeholder="Enter your work email" class="w-full p-3 bg-slate-900 border border-slate-700 rounded-lg text-white" />
|
||||
<button type="button" class="w-full bg-blue-600 hover:bg-blue-500 text-white font-bold py-3 rounded-lg transition-colors">
|
||||
Get My Roadmap
|
||||
</button>
|
||||
<p class="text-xs text-slate-500">No spam. Unsubscribe anytime.</p>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private generateMetaTitle(ctx: GenerationContext, variant: any): string {
|
||||
// Simple random pattern selection for now
|
||||
// In reality, this should come from "cartesian_patterns" loaded in context
|
||||
// But for robust fail-safe:
|
||||
const patterns = [
|
||||
`Top Rated ${ctx.niche} Company in ${ctx.city.city}`,
|
||||
`${ctx.city.city} ${ctx.niche} Experts - ${ctx.site.name || 'Official Site'}`,
|
||||
`The #1 ${ctx.niche} Service in ${ctx.city.city}, ${ctx.city.state}`,
|
||||
`Best ${ctx.niche} Agency Serving ${ctx.city.city}`
|
||||
];
|
||||
const raw = patterns[Math.floor(Math.random() * patterns.length)];
|
||||
return raw;
|
||||
}
|
||||
|
||||
private generateSlug(title: string): string {
|
||||
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
|
||||
}
|
||||
|
||||
private async getAvatarVariant(avatarId: string, gender: string) {
|
||||
// Try to fetch from Directus "avatar_variants"
|
||||
// If fail, return default neutral
|
||||
try {
|
||||
// We assume variants are stored in a singleton or we query by avatar
|
||||
// Since we don't have the ID handy, we return a safe default for this MVP test
|
||||
// to ensure it works without complex relation queries right now.
|
||||
// The GrammarEngine handles defaults if keys are missing.
|
||||
return {
|
||||
pronoun: 'they',
|
||||
ppronoun: 'them',
|
||||
pospronoun: 'their',
|
||||
isare: 'are',
|
||||
has_have: 'have',
|
||||
does_do: 'do'
|
||||
};
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
49
frontend/src/lib/cartesian/GrammarEngine.ts
Normal file
49
frontend/src/lib/cartesian/GrammarEngine.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
|
||||
/**
|
||||
* GrammarEngine
|
||||
* Resolves grammar tokens like [[PRONOUN]], [[ISARE]] based on avatar variants.
|
||||
*/
|
||||
export class GrammarEngine {
|
||||
/**
|
||||
* Resolve grammar tokens in text.
|
||||
* @param text Text containing [[TOKEN]] syntax
|
||||
* @param variant The avatar variant object (e.g. { pronoun: "he", isare: "is" })
|
||||
* @param variables Optional extra variables for function tokens like [[A_AN:{{NICHE}}]]
|
||||
*/
|
||||
static resolve(text: string, variant: Record<string, string>): string {
|
||||
if (!text) return '';
|
||||
let resolved = text;
|
||||
|
||||
// 1. Simple replacement from variant map
|
||||
// Matches [[KEY]]
|
||||
resolved = resolved.replace(/\[\[([A-Z_]+)\]\]/g, (match, key) => {
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (variant[lowerKey]) {
|
||||
return variant[lowerKey];
|
||||
}
|
||||
return match; // Return original if not found
|
||||
});
|
||||
|
||||
// 2. Handling A/An logic: [[A_AN:Word]]
|
||||
resolved = resolved.replace(/\[\[A_AN:(.*?)\]\]/g, (match, content) => {
|
||||
return GrammarEngine.a_an(content);
|
||||
});
|
||||
|
||||
// 3. Capitalization: [[CAP:word]]
|
||||
resolved = resolved.replace(/\[\[CAP:(.*?)\]\]/g, (match, content) => {
|
||||
return content.charAt(0).toUpperCase() + content.slice(1);
|
||||
});
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
static a_an(word: string): string {
|
||||
const vowels = ['a', 'e', 'i', 'o', 'u'];
|
||||
const firstChar = word.trim().charAt(0).toLowerCase();
|
||||
// Simple heuristic
|
||||
if (vowels.includes(firstChar)) {
|
||||
return `an ${word}`;
|
||||
}
|
||||
return `a ${word}`;
|
||||
}
|
||||
}
|
||||
60
frontend/src/lib/cartesian/HTMLRenderer.ts
Normal file
60
frontend/src/lib/cartesian/HTMLRenderer.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
|
||||
/**
|
||||
* HTMLRenderer (Assembler)
|
||||
* Wraps raw content blocks in formatted HTML.
|
||||
*/
|
||||
export class HTMLRenderer {
|
||||
/**
|
||||
* Render a full article from blocks.
|
||||
* @param blocks Array of processed content blocks objects
|
||||
* @returns Full HTML string
|
||||
*/
|
||||
static renderArticle(blocks: any[]): string {
|
||||
return blocks.map(block => this.renderBlock(block)).join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single block based on its structure.
|
||||
*/
|
||||
static renderBlock(block: any): string {
|
||||
let html = '';
|
||||
|
||||
// Title
|
||||
if (block.title) {
|
||||
html += `<h2>${block.title}</h2>\n`;
|
||||
}
|
||||
|
||||
// Hook
|
||||
if (block.hook) {
|
||||
html += `<p class="lead"><strong>${block.hook}</strong></p>\n`;
|
||||
}
|
||||
|
||||
// Pains (Unordered List)
|
||||
if (block.pains && block.pains.length > 0) {
|
||||
html += `<ul>\n${block.pains.map((p: string) => ` <li>${p}</li>`).join('\n')}\n</ul>\n`;
|
||||
}
|
||||
|
||||
// Solutions (Paragraphs or Ordered List)
|
||||
if (block.solutions && block.solutions.length > 0) {
|
||||
// Configurable, defaulting to paragraphs for flow
|
||||
html += block.solutions.map((s: string) => `<p>${s}</p>`).join('\n') + '\n';
|
||||
}
|
||||
|
||||
// Value Points (Checkmark List style usually)
|
||||
if (block.value_points && block.value_points.length > 0) {
|
||||
html += `<ul class="value-points">\n${block.value_points.map((v: string) => ` <li>✅ ${v}</li>`).join('\n')}\n</ul>\n`;
|
||||
}
|
||||
|
||||
// Raw Content (from Spintax/Components)
|
||||
if (block.content) {
|
||||
html += `<div class="block-content">\n${block.content}\n</div>\n`;
|
||||
}
|
||||
|
||||
// CTA
|
||||
if (block.cta) {
|
||||
html += `<div class="cta-box"><p>${block.cta}</p></div>\n`;
|
||||
}
|
||||
|
||||
return `<section class="content-block" id="${block.id || ''}">\n${html}</section>`;
|
||||
}
|
||||
}
|
||||
15
frontend/src/lib/cartesian/MetadataGenerator.ts
Normal file
15
frontend/src/lib/cartesian/MetadataGenerator.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
|
||||
/**
|
||||
* MetadataGenerator
|
||||
* Auto-generates SEO titles and descriptions.
|
||||
*/
|
||||
export class MetadataGenerator {
|
||||
static generateTitle(niche: string, city: string, state: string): string {
|
||||
// Simple formula for now - can be expanded to use patterns
|
||||
return `Top ${niche} Services in ${city}, ${state} | Verified Experts`;
|
||||
}
|
||||
|
||||
static generateDescription(niche: string, city: string): string {
|
||||
return `Looking for the best ${niche} in ${city}? We provide top-rated solutions tailored for your business needs. Get a free consultation today.`;
|
||||
}
|
||||
}
|
||||
42
frontend/src/lib/cartesian/SpintaxParser.ts
Normal file
42
frontend/src/lib/cartesian/SpintaxParser.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
|
||||
/**
|
||||
* SpintaxParser
|
||||
* Handles recursive parsing of {option1|option2} syntax.
|
||||
*/
|
||||
export class SpintaxParser {
|
||||
/**
|
||||
* Parse a string containing spintax.
|
||||
* Supports nested spintax like {Hi|Hello {World|Friend}}
|
||||
* @param text The text with spintax
|
||||
* @returns The parsed text with one option selected per block
|
||||
*/
|
||||
static parse(text: string): string {
|
||||
if (!text) return '';
|
||||
|
||||
// Regex to find the innermost spintax block: {([^{}]*)}
|
||||
// We execute this recursively until no braces remain.
|
||||
let parsed = text;
|
||||
const regex = /\{([^{}]+)\}/g;
|
||||
|
||||
while (regex.test(parsed)) {
|
||||
parsed = parsed.replace(regex, (match, content) => {
|
||||
const options = content.split('|');
|
||||
const randomOption = options[Math.floor(Math.random() * options.length)];
|
||||
return randomOption;
|
||||
});
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total variations in a spintax string.
|
||||
* (Simplified estimate for preview calculator)
|
||||
*/
|
||||
static countVariations(text: string): number {
|
||||
// Basic implementation for complexity estimation
|
||||
// Real count requiring parsing tree is complex,
|
||||
// this is a placeholder if needed for UI later.
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
33
frontend/src/lib/cartesian/UniquenessManager.ts
Normal file
33
frontend/src/lib/cartesian/UniquenessManager.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
|
||||
import module from 'node:crypto';
|
||||
const { createHash } = module;
|
||||
|
||||
/**
|
||||
* UniquenessManager
|
||||
* Handles content hashing to prevent duplicate generation.
|
||||
*/
|
||||
export class UniquenessManager {
|
||||
/**
|
||||
* Generate a unique hash for a specific combination.
|
||||
* Format: {SiteID}_{AvatarID}_{Niche}_{City}_{PatternID}
|
||||
*/
|
||||
static generateHash(siteId: string, avatarId: string, niche: string, city: string, patternId: string): string {
|
||||
const raw = `${siteId}_${avatarId}_${niche}_${city}_${patternId}`;
|
||||
return createHash('md5').update(raw).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a hash already exists in the database.
|
||||
* (Placeholder logic - real implementation queries Directus)
|
||||
*/
|
||||
static async checkExists(client: any, hash: string): Promise<boolean> {
|
||||
try {
|
||||
// This would be a Directus query
|
||||
// const res = await client.request(readItems('generated_articles', { filter: { generation_hash: { _eq: hash } }, limit: 1 }));
|
||||
// return res.length > 0;
|
||||
return false; // For now
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
frontend/src/pages/admin/content-factory.astro
Normal file
16
frontend/src/pages/admin/content-factory.astro
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
import ContentFactoryDashboard from '@/components/admin/cartesian/ContentFactoryDashboard';
|
||||
---
|
||||
|
||||
<Layout title="Cartesian Content Factory">
|
||||
<div class="p-6">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-slate-900">Cartesian Content Factory</h1>
|
||||
<p class="text-slate-600">Mission Control for High-Volume Content Generation</p>
|
||||
</div>
|
||||
|
||||
<!-- React Application Root -->
|
||||
<ContentFactoryDashboard client:only="react" />
|
||||
</div>
|
||||
</Layout>
|
||||
0
frontend/src/pages/admin/leads/[id].astro
Normal file
0
frontend/src/pages/admin/leads/[id].astro
Normal file
20
frontend/src/pages/admin/leads/index.astro
Normal file
20
frontend/src/pages/admin/leads/index.astro
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
import LeadList from '@/components/admin/leads/LeadList';
|
||||
---
|
||||
|
||||
<Layout title="Leads & Inquiries">
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-slate-100">Leads</h1>
|
||||
<p class="text-slate-400">View form submissions and inquiries.</p>
|
||||
</div>
|
||||
<button class="bg-gray-700 hover:bg-gray-600 text-white px-4 py-2 rounded-lg font-medium transition-colors">
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<LeadList client:load />
|
||||
</div>
|
||||
</Layout>
|
||||
20
frontend/src/pages/admin/pages/[id].astro
Normal file
20
frontend/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
frontend/src/pages/admin/pages/index.astro
Normal file
21
frontend/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>
|
||||
20
frontend/src/pages/admin/posts/[id].astro
Normal file
20
frontend/src/pages/admin/posts/[id].astro
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
import PostEditor from '@/components/admin/posts/PostEditor';
|
||||
|
||||
const { id } = Astro.params;
|
||||
---
|
||||
|
||||
<Layout title="Edit Post">
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<a href="/admin/posts" 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 Posts
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold text-slate-100">Edit Post</h1>
|
||||
</div>
|
||||
|
||||
<PostEditor id={id} client:only="react" />
|
||||
</div>
|
||||
</Layout>
|
||||
21
frontend/src/pages/admin/posts/index.astro
Normal file
21
frontend/src/pages/admin/posts/index.astro
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
import PostList from '@/components/admin/posts/PostList';
|
||||
---
|
||||
|
||||
<Layout title="Post 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">Posts</h1>
|
||||
<p class="text-slate-400">Manage blog posts and articles.</p>
|
||||
</div>
|
||||
<a href="/admin/posts/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 Post
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<PostList client:load />
|
||||
</div>
|
||||
</Layout>
|
||||
20
frontend/src/pages/admin/seo/articles/[id].astro
Normal file
20
frontend/src/pages/admin/seo/articles/[id].astro
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
import ArticleEditor from '@/components/admin/seo/ArticleEditor';
|
||||
|
||||
const { id } = Astro.params;
|
||||
---
|
||||
|
||||
<Layout title="Review Generated Article">
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<a href="/admin/seo/articles" 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 Articles
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold text-slate-100">Review Article</h1>
|
||||
</div>
|
||||
|
||||
<ArticleEditor id={id} client:only="react" />
|
||||
</div>
|
||||
</Layout>
|
||||
14
frontend/src/pages/admin/seo/fragments.astro
Normal file
14
frontend/src/pages/admin/seo/fragments.astro
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
---
|
||||
|
||||
<Layout title="Content Fragments">
|
||||
<div class="p-6">
|
||||
<h1 class="text-3xl font-bold text-slate-100 mb-6">Content Fragments</h1>
|
||||
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 p-8 text-center">
|
||||
<h2 class="text-xl font-bold text-white mb-2">Reusable Content Blocks</h2>
|
||||
<p class="text-slate-400">Manage global text snippets, CTAs, and bios here.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
14
frontend/src/pages/admin/seo/headlines.astro
Normal file
14
frontend/src/pages/admin/seo/headlines.astro
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
---
|
||||
|
||||
<Layout title="Headlines">
|
||||
<div class="p-6">
|
||||
<h1 class="text-3xl font-bold text-slate-100 mb-6">Headlines & Hooks</h1>
|
||||
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 p-8 text-center">
|
||||
<h2 class="text-xl font-bold text-white mb-2">Pattern Library</h2>
|
||||
<p class="text-slate-400">Manage your Cartesian Headline generation patterns here.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
19
frontend/src/pages/admin/settings.astro
Normal file
19
frontend/src/pages/admin/settings.astro
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
---
|
||||
|
||||
<Layout title="System Settings">
|
||||
<div class="p-6">
|
||||
<h1 class="text-3xl font-bold text-slate-100 mb-6">System Settings</h1>
|
||||
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 p-8 text-center">
|
||||
<div class="w-16 h-16 bg-slate-700 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<svg class="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
||||
</div>
|
||||
<h2 class="text-xl font-bold text-white mb-2">Global Configuration</h2>
|
||||
<p class="text-slate-400 max-w-md mx-auto">
|
||||
System-wide settings for API keys, user management, and global defaults will go here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
20
frontend/src/pages/admin/sites/[id].astro
Normal file
20
frontend/src/pages/admin/sites/[id].astro
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
import SiteEditor from '@/components/admin/sites/SiteEditor';
|
||||
|
||||
const { id } = Astro.params;
|
||||
---
|
||||
|
||||
<Layout title="Edit Site">
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<a href="/admin/sites" 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 Sites
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold text-slate-100">Configure Site</h1>
|
||||
</div>
|
||||
|
||||
<SiteEditor id={id} client:only="react" />
|
||||
</div>
|
||||
</Layout>
|
||||
23
frontend/src/pages/admin/sites/index.astro
Normal file
23
frontend/src/pages/admin/sites/index.astro
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
import SiteList from '@/components/admin/sites/SiteList';
|
||||
---
|
||||
|
||||
<Layout title="Site 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">My Sites</h1>
|
||||
<p class="text-slate-400">Manage your connected WordPress and Webflow sites.</p>
|
||||
</div>
|
||||
<a href="/admin/sites/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>
|
||||
Add Site
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<SiteList client:load />
|
||||
</div>
|
||||
</Layout>
|
||||
151
frontend/src/pages/api/generate-content.ts
Normal file
151
frontend/src/pages/api/generate-content.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
// @ts-nocheck
|
||||
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDirectusClient, readItem, createItem, updateItem, readItems } from '@/lib/directus/client';
|
||||
import { CartesianEngine } from '@/lib/cartesian/CartesianEngine';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const { jobId, batchSize = 5, mode } = await request.json();
|
||||
|
||||
if (!jobId) {
|
||||
return new Response(JSON.stringify({ error: 'Missing jobId' }), { status: 400 });
|
||||
}
|
||||
|
||||
const client = getDirectusClient();
|
||||
const engine = new CartesianEngine(client);
|
||||
|
||||
// 1. Fetch Job
|
||||
const job = await client.request(readItem('generation_jobs' as any, jobId));
|
||||
if (!job || job.status === 'Complete') {
|
||||
return new Response(JSON.stringify({ message: 'Job not found or complete' }), { status: 404 });
|
||||
}
|
||||
|
||||
// 2. Setup Context
|
||||
const filters = job.filters || {};
|
||||
const isFullSiteSetup = mode === 'full_site_setup' || (job.target_quantity === 10 && filters.avatars?.length > 5); // Infer or explicit
|
||||
|
||||
// Fetch Site Data
|
||||
const siteId = job.site_id;
|
||||
const site = await client.request(readItem('sites' as any, siteId));
|
||||
|
||||
let generatedCount = 0;
|
||||
let limit = job.target_quantity;
|
||||
let offset = job.current_offset || 0;
|
||||
|
||||
// Fetch Global Resources (Optimization: Load once)
|
||||
const allNiches = await client.request(readItems('avatars' as any)); // Actually this returns top level obj? No, we need structure.
|
||||
// For MVP, simplistic fetch inside loop or robust fetch here.
|
||||
// Let's assume engine handles detail fetching for now or we rely on basic info.
|
||||
|
||||
// 3. SPECIAL OPS: Full Site Setup (Home + Blog)
|
||||
// Only run if offset is 0 and mode is set
|
||||
if (offset === 0 && isFullSiteSetup) {
|
||||
console.log("🚀 Executing Full Site Setup (Showcase Mode)...");
|
||||
|
||||
// A. Home Page
|
||||
const homeContext = {
|
||||
avatar: { id: 'generic' },
|
||||
niche: 'General',
|
||||
city: { city: 'Los Angeles', state: 'CA' }, // Default
|
||||
site: site,
|
||||
// Showcase Layout: Hero -> Avatar Grid -> Consult Form
|
||||
template: { structure_json: ['block_01_zapier_fix', 'block_11_avatar_showcase', 'block_12_consultation_form'] }
|
||||
};
|
||||
const homeArticle = await engine.generateArticle(homeContext);
|
||||
await client.request(createItem('generated_articles' as any, {
|
||||
site_id: siteId,
|
||||
title: "Home", // Force override
|
||||
slug: "home", // Force override
|
||||
html_content: homeArticle.html_content,
|
||||
meta_desc: "Welcome to our agency.",
|
||||
is_published: true,
|
||||
}));
|
||||
generatedCount++;
|
||||
|
||||
// B. Blog Archive
|
||||
// Ideally a page template, but we'll make a placeholder article for now
|
||||
await client.request(createItem('generated_articles' as any, {
|
||||
site_id: siteId,
|
||||
title: "Insights & Articles",
|
||||
slug: "blog",
|
||||
html_content: "<div class='archive-feed'>[POST_FEED_PLACEHOLDER]</div>",
|
||||
meta_desc: "Read our latest insights.",
|
||||
is_published: true,
|
||||
}));
|
||||
generatedCount++;
|
||||
}
|
||||
|
||||
// 4. Generate Standard Batch
|
||||
// We will loop until batchSize is met or limit reached.
|
||||
|
||||
// Load Resources needed for randomization
|
||||
const availableAvatars = filters.avatars && filters.avatars.length ? filters.avatars : ['scaling_founder'];
|
||||
// We need city IDs. Queries 'geo_locations'
|
||||
// For efficiency, we scan 10 cities.
|
||||
const cities = await client.request(readItems('geo_locations' as any, { limit: 20 }));
|
||||
|
||||
while (generatedCount < batchSize && (offset + generatedCount) < limit) {
|
||||
// SEQUENTIAL AVATAR SELECTION (for Showcase)
|
||||
// If full site setup, we want to cycle through avatars 1-by-1 to ensure coverage
|
||||
let avatarId;
|
||||
if (isFullSiteSetup) {
|
||||
// Use offset + generatedCount to pick index (modulo length)
|
||||
// Subtract 2 for Home/Blog if they were generated in this batch?
|
||||
// Actually offset tracks total items.
|
||||
// If offset=0, items 0,1 are home/blog. item 2 is post #1.
|
||||
// So we use (offset + generatedCount) % availableAvatars.length?
|
||||
// But wait, if offset=0, loop starts at generatedCount=2.
|
||||
// (0 + 2) % 10 = 2.
|
||||
avatarId = availableAvatars[(offset + generatedCount) % availableAvatars.length];
|
||||
} else {
|
||||
avatarId = availableAvatars[Math.floor(Math.random() * availableAvatars.length)];
|
||||
}
|
||||
|
||||
const randCity = cities[Math.floor(Math.random() * cities.length)];
|
||||
|
||||
// Fetch real avatar to get niche? Or use ID mapping?
|
||||
// We need the Niche string for the engine.
|
||||
// We'll quick-fetch the avatar Item (not optimal in loop but safe for 10 items)
|
||||
const avatarItem = await client.request(readItem('avatars' as any, avatarId));
|
||||
const randNiche = avatarItem.business_niches ? avatarItem.business_niches[Math.floor(Math.random() * avatarItem.business_niches.length)] : 'Business';
|
||||
|
||||
const context = {
|
||||
avatar: avatarItem,
|
||||
niche: randNiche,
|
||||
city: randCity,
|
||||
site: site,
|
||||
template: { structure_json: ['block_03_fix_first_scale_second', 'block_05_stop_wasting_dollars'] } // Randomize this later
|
||||
};
|
||||
|
||||
const article = await engine.generateArticle(context);
|
||||
|
||||
// Save
|
||||
await client.request(createItem('generated_articles' as any, {
|
||||
site_id: siteId,
|
||||
title: article.title,
|
||||
slug: article.slug + '-' + Math.floor(Math.random() * 1000), // Unique slug
|
||||
html_content: article.html_content,
|
||||
meta_desc: article.meta_desc,
|
||||
is_published: true, // Auto publish for test
|
||||
}));
|
||||
|
||||
generatedCount++;
|
||||
}
|
||||
|
||||
// 5. Update Job
|
||||
await client.request(updateItem('generation_jobs' as any, jobId, {
|
||||
current_offset: offset + generatedCount,
|
||||
status: (offset + generatedCount >= limit) ? 'Complete' : 'Processing'
|
||||
}));
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
generated: generatedCount,
|
||||
completed: (offset + generatedCount >= limit)
|
||||
}), { status: 200 });
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Generation Error:", error);
|
||||
return new Response(JSON.stringify({ error: error.message }), { status: 500 });
|
||||
}
|
||||
}
|
||||
35
frontend/src/pages/api/preview-article.ts
Normal file
35
frontend/src/pages/api/preview-article.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
|
||||
import type { APIRoute } from 'astro';
|
||||
import { getDirectusClient, readItem, readItems } from '@/lib/directus/client';
|
||||
import { CartesianEngine } from '@/lib/cartesian/CartesianEngine';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const { siteId, avatarId, niche, cityId, templateId } = await request.json();
|
||||
|
||||
const client = getDirectusClient();
|
||||
const engine = new CartesianEngine(client);
|
||||
|
||||
// Fetch Context Data
|
||||
const [site, avatar, city, template] = await Promise.all([
|
||||
client.request(readItem('sites', siteId)),
|
||||
client.request(readItem('avatars', avatarId)),
|
||||
client.request(readItem('geo_locations', cityId)), // Assuming cityId provided
|
||||
client.request(readItem('article_templates', templateId))
|
||||
]);
|
||||
|
||||
const context = {
|
||||
avatar,
|
||||
niche: niche || avatar.business_niches[0], // fallback
|
||||
city,
|
||||
site,
|
||||
template
|
||||
};
|
||||
|
||||
const article = await engine.generateArticle(context);
|
||||
|
||||
return new Response(JSON.stringify(article), { status: 200 });
|
||||
} catch (error: any) {
|
||||
return new Response(JSON.stringify({ error: error.message }), { status: 500 });
|
||||
}
|
||||
}
|
||||
14
frontend/src/pages/api/preview-permutations.ts
Normal file
14
frontend/src/pages/api/preview-permutations.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
|
||||
import type { APIRoute } from 'astro';
|
||||
|
||||
export const POST: APIRoute = async ({ request }) => {
|
||||
try {
|
||||
const { avatars, niches, cities, patterns } = await request.json();
|
||||
|
||||
const count = (avatars?.length || 0) * (niches?.length || 0) * (cities?.length || 0) * (patterns?.length || 0);
|
||||
|
||||
return new Response(JSON.stringify({ count }), { status: 200 });
|
||||
} catch (e) {
|
||||
return new Response(JSON.stringify({ count: 0 }), { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -133,109 +133,95 @@ export type FragmentType =
|
||||
| 'pillar_6_backlinks'
|
||||
| 'faq_section';
|
||||
|
||||
// ... (Existing types preserved above)
|
||||
|
||||
// Cartesian Engine Types
|
||||
export interface GenerationJob {
|
||||
id: string;
|
||||
site_id: string | Site;
|
||||
target_quantity: number;
|
||||
status: 'Pending' | 'Processing' | 'Complete' | 'Failed';
|
||||
filters: Record<string, any>; // { avatars: [], niches: [], cities: [], patterns: [] }
|
||||
current_offset: number;
|
||||
date_created?: string;
|
||||
}
|
||||
|
||||
export interface ArticleTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
structure_json: string[]; // Array of block IDs
|
||||
}
|
||||
|
||||
export interface Avatar {
|
||||
id: string; // key
|
||||
base_name: string;
|
||||
business_niches: string[];
|
||||
wealth_cluster: string;
|
||||
}
|
||||
|
||||
export interface AvatarVariant {
|
||||
id: string;
|
||||
avatar_id: string;
|
||||
variants_json: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface GeoCluster {
|
||||
id: string;
|
||||
cluster_name: string;
|
||||
}
|
||||
|
||||
export interface GeoLocation {
|
||||
id: string;
|
||||
cluster: string | GeoCluster;
|
||||
city: string;
|
||||
state: string;
|
||||
zip_focus?: string;
|
||||
}
|
||||
|
||||
export interface SpintaxDictionary {
|
||||
id: string;
|
||||
category: string;
|
||||
words: string[];
|
||||
}
|
||||
|
||||
export interface CartesianPattern {
|
||||
id: string;
|
||||
pattern_id: string;
|
||||
category: string;
|
||||
formula: string;
|
||||
}
|
||||
|
||||
export interface OfferBlockUniversal {
|
||||
id: string;
|
||||
block_id: string;
|
||||
title: string;
|
||||
hook_generator: string;
|
||||
universal_pains: string[];
|
||||
universal_solutions: string[];
|
||||
universal_value_points: string[];
|
||||
cta_spintax: string;
|
||||
}
|
||||
|
||||
export interface OfferBlockPersonalized {
|
||||
id: string;
|
||||
block_related_id: string;
|
||||
avatar_related_id: string;
|
||||
pains: string[];
|
||||
solutions: string[];
|
||||
value_points: string[];
|
||||
}
|
||||
|
||||
// Updated GeneratedArticle to match Init Schema
|
||||
export interface GeneratedArticle {
|
||||
id: string;
|
||||
site: string | Site;
|
||||
campaign?: string | CampaignMaster;
|
||||
headline: string;
|
||||
meta_title: string;
|
||||
meta_description: string;
|
||||
full_html_body: string;
|
||||
word_count: number;
|
||||
is_published: boolean;
|
||||
featured_image?: string;
|
||||
location_state?: string;
|
||||
location_county?: string;
|
||||
location_city?: string;
|
||||
date_created?: string;
|
||||
date_updated?: string;
|
||||
}
|
||||
|
||||
export interface ImageTemplate {
|
||||
id: string;
|
||||
site?: string | Site;
|
||||
name: string;
|
||||
svg_source: string;
|
||||
preview?: string;
|
||||
is_default: boolean;
|
||||
date_created?: string;
|
||||
}
|
||||
|
||||
// Location Types
|
||||
export interface LocationState {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
country_code: string;
|
||||
}
|
||||
|
||||
export interface LocationCounty {
|
||||
id: string;
|
||||
name: string;
|
||||
state: string | LocationState;
|
||||
fips_code?: string;
|
||||
population?: number;
|
||||
}
|
||||
|
||||
export interface LocationCity {
|
||||
id: string;
|
||||
name: string;
|
||||
county: string | LocationCounty;
|
||||
state: string | LocationState;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
population?: number;
|
||||
postal_code?: string;
|
||||
ranking?: number;
|
||||
}
|
||||
|
||||
// Lead Capture Types
|
||||
export interface Lead {
|
||||
id: string;
|
||||
site: string | Site;
|
||||
name: string;
|
||||
email: string;
|
||||
phone?: string;
|
||||
message?: string;
|
||||
source?: string;
|
||||
date_created?: string;
|
||||
}
|
||||
|
||||
export interface NewsletterSubscriber {
|
||||
id: string;
|
||||
site: string | Site;
|
||||
email: string;
|
||||
status: 'subscribed' | 'unsubscribed';
|
||||
date_created?: string;
|
||||
}
|
||||
|
||||
// Form Builder Types
|
||||
export interface Form {
|
||||
id: string;
|
||||
site: string | Site;
|
||||
name: string;
|
||||
fields: FormField[];
|
||||
submit_action: 'email' | 'webhook' | 'both';
|
||||
submit_email?: string;
|
||||
submit_webhook?: string;
|
||||
success_message?: string;
|
||||
redirect_url?: string;
|
||||
}
|
||||
|
||||
export interface FormField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'email' | 'phone' | 'textarea' | 'select' | 'checkbox';
|
||||
required: boolean;
|
||||
options?: string[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface FormSubmission {
|
||||
id: string;
|
||||
form: string | Form;
|
||||
site: string | Site;
|
||||
data: Record<string, any>;
|
||||
site_id: number; // or string depending on schema
|
||||
title: string;
|
||||
slug: string;
|
||||
html_content: string;
|
||||
generation_hash: string;
|
||||
meta_desc?: string;
|
||||
is_published?: boolean;
|
||||
sync_status?: string;
|
||||
date_created?: string;
|
||||
}
|
||||
|
||||
@@ -249,14 +235,29 @@ export interface SparkSchema {
|
||||
globals: Globals[];
|
||||
navigation: Navigation[];
|
||||
authors: Author[];
|
||||
|
||||
// Legacy SEO Engine (Keep for compatibility if needed)
|
||||
campaign_masters: CampaignMaster[];
|
||||
headline_inventory: HeadlineInventory[];
|
||||
content_fragments: ContentFragment[];
|
||||
generated_articles: GeneratedArticle[];
|
||||
image_templates: ImageTemplate[];
|
||||
locations_states: LocationState[];
|
||||
locations_counties: LocationCounty[];
|
||||
locations_cities: LocationCity[];
|
||||
|
||||
// New Cartesian Engine
|
||||
generation_jobs: GenerationJob[];
|
||||
article_templates: ArticleTemplate[];
|
||||
avatars: Avatar[];
|
||||
avatar_variants: AvatarVariant[];
|
||||
geo_clusters: GeoCluster[];
|
||||
geo_locations: GeoLocation[];
|
||||
spintax_dictionaries: SpintaxDictionary[];
|
||||
cartesian_patterns: CartesianPattern[];
|
||||
offer_blocks_universal: OfferBlockUniversal[];
|
||||
offer_blocks_personalized: OfferBlockPersonalized[];
|
||||
generated_articles: GeneratedArticle[];
|
||||
|
||||
leads: Lead[];
|
||||
newsletter_subscribers: NewsletterSubscriber[];
|
||||
forms: Form[];
|
||||
|
||||
Reference in New Issue
Block a user