Feature: Complete Admin UI Overhaul, Content Factory Showcase Mode, and Site Management

This commit is contained in:
cawcenter
2025-12-12 18:25:31 -05:00
parent 7a9b7ec86e
commit d8db5f42cf
59 changed files with 6277 additions and 186 deletions

View File

@@ -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>
);
}

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

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -1,3 +1,4 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />
interface ImportMetaEnv {

View File

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

View 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 {};
}
}
}

View 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}`;
}
}

View 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>`;
}
}

View 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.`;
}
}

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

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,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>

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

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

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

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

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

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

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

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View File

@@ -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[];