God Mode Valhalla: Initial Standalone Commit

This commit is contained in:
cawcenter
2025-12-14 19:19:14 -05:00
commit 153102b23e
127 changed files with 30781 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules
dist
.env
.DS_Store
.astro
npm-debug.log
yarn-error.log
coverage
.vscode

42
Dockerfile Normal file
View File

@@ -0,0 +1,42 @@
# God Mode (Valhalla) Dockerfile
# Optimized for reliable builds with full dependencies
# 1. Base Image
FROM node:20-alpine AS base
WORKDIR /app
# Install libc6-compat for sharp/performance
RUN apk add --no-cache libc6-compat
# 2. Dependencies
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
# Use npm install for robustness (npm ci can fail if lockfile is out of sync)
RUN npm install --legacy-peer-deps
# 3. Builder
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# 4. Runner
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=4321
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 astro
COPY --from=builder /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
USER astro
EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]

23
astro.config.mjs Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import react from '@astrojs/react';
import tailwind from '@astrojs/tailwind';
// https://astro.build/config
export default defineConfig({
output: 'server',
adapter: node({
mode: 'standalone'
}),
integrations: [
react(),
tailwind({
applyBaseStyles: false,
}),
],
vite: {
ssr: {
noExternal: ['path-to-regexp']
}
}
});

View File

@@ -0,0 +1,22 @@
-- Create sites table for Multi-Tenancy
CREATE TABLE IF NOT EXISTS sites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
domain VARCHAR(255) UNIQUE NOT NULL,
status VARCHAR(50) DEFAULT 'active', -- active, maintenance, archived
config JSONB DEFAULT '{}', -- branding, SEO settings
client_id VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Index for fast domain lookups
CREATE INDEX IF NOT EXISTS idx_sites_domain ON sites (domain);
-- Insert the Platform/Admin site default
INSERT INTO
sites (domain, status, config)
VALUES (
'spark.jumpstartscaling.com',
'active',
'{"type": "admin"}'
) ON CONFLICT (domain) DO NOTHING;

19880
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

99
package.json Normal file
View File

@@ -0,0 +1,99 @@
{
"name": "spark-god-mode",
"type": "module",
"version": "1.0.0",
"scripts": {
"dev": "astro dev",
"start": "node ./dist/server/entry.mjs",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/node": "^8.2.6",
"@astrojs/partytown": "^2.1.4",
"@astrojs/react": "^3.2.0",
"@astrojs/sitemap": "^3.6.0",
"@astrojs/tailwind": "^5.1.0",
"@bull-board/api": "^6.15.0",
"@bull-board/express": "^6.15.0",
"@craftjs/core": "^0.2.12",
"@craftjs/utils": "^0.2.5",
"@directus/sdk": "^17.0.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@hookform/resolvers": "^5.2.2",
"@nanostores/react": "^1.0.0",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@tanstack/react-query": "^5.90.12",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.13",
"@tiptap/extension-placeholder": "^3.13.0",
"@tiptap/react": "^3.13.0",
"@tiptap/starter-kit": "^3.13.0",
"@tremor/react": "^3.18.7",
"@turf/turf": "^7.3.1",
"@types/leaflet": "^1.9.21",
"@types/lodash-es": "^4.17.12",
"@types/papaparse": "^5.5.2",
"@types/react-syntax-highlighter": "^15.5.13",
"@vite-pwa/astro": "^1.2.0",
"astro": "^4.7.0",
"astro-imagetools": "^0.9.0",
"bullmq": "^5.66.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"framer-motion": "^12.23.26",
"html-to-image": "^1.11.13",
"immer": "^11.0.1",
"ioredis": "^5.8.2",
"leaflet": "^1.9.4",
"lodash-es": "^4.17.21",
"lucide-react": "^0.346.0",
"lzutf8": "^0.6.3",
"nanoid": "^5.0.5",
"nanostores": "^1.1.0",
"papaparse": "^5.5.3",
"pdfmake": "^0.2.20",
"pg": "^8.16.3",
"react": "^18.3.1",
"react-contenteditable": "^3.3.7",
"react-diff-viewer-continued": "^3.4.0",
"react-dom": "^18.3.1",
"react-dropzone": "^14.3.8",
"react-flow-renderer": "^10.3.17",
"react-hook-form": "^7.68.0",
"react-leaflet": "^4.2.1",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.0",
"reactflow": "^11.11.4",
"recharts": "^3.5.1",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^2.6.0",
"tailwindcss": "^3.4.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.25.76",
"zustand": "^5.0.9"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"rollup-plugin-visualizer": "^6.0.5",
"sharp": "^0.33.3",
"typescript": "^5.4.0",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-inspect": "^11.3.3"
}
}

View File

@@ -0,0 +1,61 @@
import React, { useEffect, useState } from 'react';
type SystemMetric = {
label: string;
status: 'active' | 'standby' | 'online' | 'connected' | 'ready' | 'error';
color: string;
};
export default function SystemStatus() {
const [metrics, setMetrics] = useState<SystemMetric[]>([
{ label: 'Intelligence Station', status: 'active', color: 'bg-green-500' },
{ label: 'Production Station', status: 'active', color: 'bg-green-500' },
{ label: 'WordPress Ignition', status: 'standby', color: 'bg-yellow-500' },
{ label: 'Core API', status: 'online', color: 'bg-blue-500' },
{ label: 'Directus DB', status: 'connected', color: 'bg-emerald-500' },
{ label: 'WP Connection', status: 'ready', color: 'bg-green-500' }
]);
// In a real scenario, we would poll an API here.
// For now, we simulate the "Live" feeling or check basic connectivity.
useEffect(() => {
const checkHealth = async () => {
// We can check Directus health via SDK in future
// For now, we trust the static state or toggle visually to show life
};
checkHealth();
}, []);
return (
<div className="p-4 rounded-lg bg-slate-900 border border-slate-700 shadow-xl w-full">
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider mb-3 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
Sub-Station Status
</h3>
<div className="grid grid-cols-1 gap-2">
{metrics.map((m, idx) => (
<div key={idx} className="flex items-center justify-between group">
<span className="text-sm text-slate-300 font-medium group-hover:text-white transition-colors">{m.label}</span>
<div className="flex items-center gap-2">
<span className={`text-[10px] uppercase font-bold px-1.5 py-0.5 rounded text-white ${getStatusColor(m.status)}`}>
{m.status}
</span>
</div>
</div>
))}
</div>
</div>
);
}
function getStatusColor(status: string) {
switch (status) {
case 'active': return 'bg-green-600';
case 'standby': return 'bg-yellow-600';
case 'online': return 'bg-blue-600';
case 'connected': return 'bg-emerald-600';
case 'ready': return 'bg-green-600';
case 'error': return 'bg-red-600';
default: return 'bg-gray-600';
}
}

View File

@@ -0,0 +1,155 @@
import { useState, useEffect } from 'react';
interface SystemStatus {
coreApi: 'online' | 'offline' | 'checking';
database: 'connected' | 'disconnected' | 'checking';
wpConnection: 'ready' | 'error' | 'checking';
}
interface LogEntry {
time: string;
message: string;
type: 'info' | 'success' | 'warning' | 'error';
}
export default function SystemStatusBar() {
const [status, setStatus] = useState<SystemStatus>({
coreApi: 'checking',
database: 'checking',
wpConnection: 'checking'
});
const [logs, setLogs] = useState<LogEntry[]>([]);
const [showLogs, setShowLogs] = useState(false);
useEffect(() => {
checkStatus();
const interval = setInterval(checkStatus, 30000);
return () => clearInterval(interval);
}, []);
const addLog = (message: string, type: LogEntry['type']) => {
const newLog: LogEntry = {
time: new Date().toLocaleTimeString(),
message,
type
};
setLogs(prev => [newLog, ...prev].slice(0, 50));
};
const checkStatus = async () => {
try {
// We check OUR OWN backend API route which can then proxy/check other services or just confirm this server is up.
// This avoids CORS issues and ensures the frontend server is actually serving API routes correctly.
const response = await fetch('/api/system/health');
if (response.ok) {
setStatus({
coreApi: 'online',
database: 'connected',
wpConnection: 'ready'
});
addLog('System check passed', 'success');
} else {
throw new Error(`Health check failed: ${response.status}`);
}
} catch (error) {
console.error('Status check failed:', error);
setStatus({
coreApi: 'offline',
database: 'disconnected',
wpConnection: 'error'
});
addLog(`System check failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error');
}
};
const getStatusColor = (state: string) => {
switch (state) {
case 'online':
case 'connected':
case 'ready':
return 'text-green-400';
case 'offline':
case 'disconnected':
case 'error':
return 'text-red-400';
case 'checking':
return 'text-yellow-400';
default:
return 'text-slate-400';
}
};
const getLogColor = (type: LogEntry['type']) => {
switch (type) {
case 'success':
return 'text-green-400';
case 'error':
return 'text-red-400';
case 'warning':
return 'text-yellow-400';
default:
return 'text-slate-400';
}
};
return (
<div className="fixed bottom-0 left-0 right-0 z-50 bg-titanium border-t border-edge-normal shadow-xl">
<div className="container mx-auto px-4 py-3">
<div className="flex items-center justify-between gap-4">
<h3 className="spark-label text-white">API & Logistics</h3>
<div className="flex items-center gap-6 flex-1">
<div className="flex items-center gap-2 text-sm">
<span className="text-silver">Core API</span>
<span className={getStatusColor(status.coreApi)}>
{status.coreApi.charAt(0).toUpperCase() + status.coreApi.slice(1)}
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-silver">Database (Directus)</span>
<span className={getStatusColor(status.database)}>
{status.database.charAt(0).toUpperCase() + status.database.slice(1)}
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span className="text-silver">WP Connection</span>
<span className={getStatusColor(status.wpConnection)}>
{status.wpConnection.charAt(0).toUpperCase() + status.wpConnection.slice(1)}
</span>
</div>
</div>
<button
onClick={() => setShowLogs(!showLogs)}
className="spark-btn-ghost text-sm"
>
{showLogs ? 'Hide' : 'Show'} Processing Log
</button>
</div>
</div>
{showLogs && (
<div className="border-t border-edge-subtle bg-void">
<div className="container mx-auto px-4 py-3 max-h-48 overflow-y-auto">
<div className="space-y-1">
{logs.length === 0 ? (
<div className="text-sm text-silver/50 italic">No recent activity</div>
) : (
logs.map((log, index) => (
<div key={index} className="flex items-start gap-2 text-sm font-mono">
<span className="text-silver/50 shrink-0">[{log.time}]</span>
<span className={getLogColor(log.type)}>{log.message}</span>
</div>
))
)}
</div>
</div>
</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,68 @@
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 { Posts as Post } from '@/lib/schemas';
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>
</tr>
))}
{posts.length === 0 && (
<tr>
<td colSpan={3} className="px-6 py-12 text-center text-slate-500">
No posts found.
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,141 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getDirectusClient, readItems, createItem, updateItem, deleteItem } from '@/lib/directus/client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Plus, Trash2, Save, GripVertical } from 'lucide-react';
import { toast } from 'sonner';
const client = getDirectusClient();
interface NavItem {
id: string;
label: string;
url: string;
sort: number;
}
interface NavigationManagerProps {
siteId: string;
}
export default function NavigationManager({ siteId }: NavigationManagerProps) {
const queryClient = useQueryClient();
const [newItem, setNewItem] = useState({ label: '', url: '' });
const { data: items = [], isLoading } = useQuery({
queryKey: ['navigation', siteId],
queryFn: async () => {
// @ts-ignore
const res = await client.request(readItems('navigation', {
filter: { site: { _eq: siteId } },
sort: ['sort']
}));
return res as unknown as NavItem[];
}
});
const createMutation = useMutation({
mutationFn: async () => {
// @ts-ignore
await client.request(createItem('navigation', {
...newItem,
site: siteId,
sort: items.length + 1
}));
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['navigation', siteId] });
setNewItem({ label: '', url: '' });
toast.success('Menu item added');
}
});
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
// @ts-ignore
await client.request(deleteItem('navigation', id));
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['navigation', siteId] });
toast.success('Menu item deleted');
}
});
const updateSortMutation = useMutation({
mutationFn: async ({ id, sort }: { id: string, sort: number }) => {
// @ts-ignore
await client.request(updateItem('navigation', id, { sort }));
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['navigation', siteId] });
}
});
return (
<div className="space-y-6 max-w-4xl">
<div className="bg-zinc-900/50 border border-zinc-800 rounded-lg p-4">
<h3 className="text-white font-medium mb-4">Add Menu Item</h3>
<div className="flex gap-4">
<Input
placeholder="Label (e.g. Home)"
value={newItem.label}
onChange={e => setNewItem({ ...newItem, label: e.target.value })}
className="bg-zinc-950 border-zinc-800"
/>
<Input
placeholder="URL (e.g. /home)"
value={newItem.url}
onChange={e => setNewItem({ ...newItem, url: e.target.value })}
className="bg-zinc-950 border-zinc-800"
/>
<Button onClick={() => createMutation.mutate()} disabled={!newItem.label} className="bg-blue-600 hover:bg-blue-500 whitespace-nowrap">
<Plus className="mr-2 h-4 w-4" /> Add Item
</Button>
</div>
</div>
<div className="rounded-md border border-zinc-800 bg-zinc-900/50 overflow-hidden">
<Table>
<TableHeader className="bg-zinc-950">
<TableRow className="border-zinc-800 hover:bg-zinc-950">
<TableHead className="w-[50px]"></TableHead>
<TableHead className="text-zinc-400">Label</TableHead>
<TableHead className="text-zinc-400">URL</TableHead>
<TableHead className="text-zinc-400 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center text-zinc-500">
No menu items. Add one above.
</TableCell>
</TableRow>
) : (
items.map((item, index) => (
<TableRow key={item.id} className="border-zinc-800 hover:bg-zinc-900/50">
<TableCell>
<GripVertical className="h-4 w-4 text-zinc-600 cursor-grab" />
</TableCell>
<TableCell className="font-medium text-white">
{item.label}
</TableCell>
<TableCell className="text-zinc-400 font-mono text-xs">
{item.url}
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon" className="h-8 w-8 text-zinc-600 hover:text-red-500" onClick={() => deleteMutation.mutate(item.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,257 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getDirectusClient, readItem, updateItem } from '@/lib/directus/client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { GripVertical, Plus, Trash2, LayoutTemplate, Type, Image as ImageIcon, Save, ArrowLeft } from 'lucide-react';
import { toast } from 'sonner';
const client = getDirectusClient();
interface PageBlock {
id: string;
block_type: 'hero' | 'content' | 'features' | 'cta';
block_config: any;
}
interface Page {
id: string;
title: string;
permalink: string;
status: string;
blocks: PageBlock[];
}
interface PageEditorProps {
pageId: string;
onBack?: () => void;
}
export default function PageEditor({ pageId, onBack }: PageEditorProps) {
const queryClient = useQueryClient();
const [blocks, setBlocks] = useState<PageBlock[]>([]);
const [pageMeta, setPageMeta] = useState<Partial<Page>>({});
const { isLoading } = useQuery({
queryKey: ['page', pageId],
queryFn: async () => {
// @ts-ignore
const res = await client.request(readItem('pages', pageId));
const page = res as unknown as Page;
setBlocks(page.blocks || []);
setPageMeta({ title: page.title, permalink: page.permalink, status: page.status });
return page;
},
enabled: !!pageId
});
const saveMutation = useMutation({
mutationFn: async () => {
// @ts-ignore
await client.request(updateItem('pages', pageId, {
...pageMeta,
blocks: blocks
}));
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['page', pageId] });
toast.success('Page saved successfully');
}
});
const addBlock = (block_type: PageBlock['block_type']) => {
const newBlock: PageBlock = {
id: crypto.randomUUID(),
block_type,
block_config: block_type === 'hero' ? { title: 'New Hero', subtitle: 'Subtitle here', bg: 'default' } :
block_type === 'content' ? { content: '<p>Start writing...</p>' } :
block_type === 'features' ? { items: [{ title: 'Feature 1', desc: 'Description' }] } :
{ label: 'Click Me', url: '#' }
};
setBlocks([...blocks, newBlock]);
};
const updateBlock = (id: string, config: any) => {
setBlocks(blocks.map(b => b.id === id ? { ...b, block_config: { ...b.block_config, ...config } } : b));
};
const removeBlock = (id: string) => {
setBlocks(blocks.filter(b => b.id !== id));
};
const moveBlock = (index: number, direction: 'up' | 'down') => {
const newBlocks = [...blocks];
if (direction === 'up' && index > 0) {
[newBlocks[index - 1], newBlocks[index]] = [newBlocks[index], newBlocks[index - 1]];
} else if (direction === 'down' && index < newBlocks.length - 1) {
[newBlocks[index + 1], newBlocks[index]] = [newBlocks[index], newBlocks[index + 1]];
}
setBlocks(newBlocks);
};
if (isLoading) return <div className="p-8 text-center text-zinc-500">Loading editor...</div>;
return (
<div className="flex h-screen bg-zinc-950 text-white overflow-hidden">
{/* Sidebar Controls */}
<div className="w-80 border-r border-zinc-800 bg-zinc-900/50 flex flex-col">
<div className="p-4 border-b border-zinc-800 flex items-center gap-2">
{onBack && <Button variant="ghost" size="icon" onClick={onBack}><ArrowLeft className="h-4 w-4" /></Button>}
<div>
<h2 className="font-bold text-sm">Page Editor</h2>
<Input
value={pageMeta.title || ''}
onChange={e => setPageMeta({ ...pageMeta, title: e.target.value })}
className="h-7 text-xs bg-transparent border-0 px-0 focus-visible:ring-0 placeholder:text-zinc-600"
placeholder="Page Title"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
<div>
<label className="text-xs uppercase font-bold text-zinc-500 mb-2 block">Add Blocks</label>
<div className="grid grid-cols-2 gap-2">
<Button variant="outline" className="justify-start border-zinc-800 hover:bg-zinc-800" onClick={() => addBlock('hero')}>
<LayoutTemplate className="mr-2 h-4 w-4 text-purple-400" /> Hero
</Button>
<Button variant="outline" className="justify-start border-zinc-800 hover:bg-zinc-800" onClick={() => addBlock('content')}>
<Type className="mr-2 h-4 w-4 text-blue-400" /> Content
</Button>
<Button variant="outline" className="justify-start border-zinc-800 hover:bg-zinc-800" onClick={() => addBlock('features')}>
<ImageIcon className="mr-2 h-4 w-4 text-green-400" /> Features
</Button>
</div>
</div>
<div>
<label className="text-xs uppercase font-bold text-zinc-500 mb-2 block">Page Settings</label>
<div className="space-y-3">
<div className="space-y-1">
<label className="text-xs text-zinc-400">Permalink</label>
<Input
value={pageMeta.permalink || ''}
onChange={e => setPageMeta({ ...pageMeta, permalink: e.target.value })}
className="bg-zinc-950 border-zinc-800 h-8"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-zinc-400">Status</label>
<select
className="w-full bg-zinc-950 border border-zinc-800 rounded px-2 py-1 text-sm h-8"
value={pageMeta.status || 'draft'}
onChange={e => setPageMeta({ ...pageMeta, status: e.target.value })}
>
<option value="draft">Draft</option>
<option value="published">Published</option>
</select>
</div>
</div>
</div>
</div>
<div className="p-4 border-t border-zinc-800">
<Button className="w-full bg-blue-600 hover:bg-blue-500" onClick={() => saveMutation.mutate()}>
<Save className="mr-2 h-4 w-4" /> Save Page
</Button>
</div>
</div>
{/* Visual Canvas (Preview + Edit) */}
<div className="flex-1 overflow-y-auto bg-zinc-950 p-8">
<div className="max-w-4xl mx-auto space-y-4">
{blocks.map((block, index) => (
<Card key={block.id} className="bg-zinc-900 border-zinc-800 relative group transition-all hover:border-zinc-700">
{/* Block Actions */}
<div className="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1 bg-zinc-900 border border-zinc-800 rounded-md p-1 shadow-xl z-20">
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => moveBlock(index, 'up')}><span className="sr-only">Up</span></Button>
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => moveBlock(index, 'down')}><span className="sr-only">Down</span></Button>
<Button variant="ghost" size="icon" className="h-6 w-6 text-red-500" onClick={() => removeBlock(block.id)}><Trash2 className="h-3 w-3" /></Button>
</div>
<CardContent className="p-6">
{/* Type Label */}
<div className="absolute left-0 top-0 bg-zinc-800 text-zinc-500 text-[10px] uppercase font-bold px-2 py-1 rounded-br opacity-50">{block.block_type}</div>
{/* HERO EDITOR */}
{block.block_type === 'hero' && (
<div className="text-center space-y-4 py-8">
<Input
value={block.block_config.title}
onChange={e => updateBlock(block.id, { title: e.target.value })}
className="text-4xl font-bold bg-transparent border-0 text-center placeholder:text-zinc-700 h-auto focus-visible:ring-0 p-0"
placeholder="Hero Headline"
/>
<Input
value={block.block_config.subtitle}
onChange={e => updateBlock(block.id, { subtitle: e.target.value })}
className="text-xl text-zinc-400 bg-transparent border-0 text-center placeholder:text-zinc-700 h-auto focus-visible:ring-0 p-0"
placeholder="Hero Subtitle"
/>
</div>
)}
{/* CONTENT EDITOR */}
{block.block_type === 'content' && (
<div className="space-y-2">
<Textarea
value={block.block_config.content}
onChange={e => updateBlock(block.id, { content: e.target.value })}
className="min-h-[150px] bg-zinc-950 border-zinc-800 font-serif text-lg leading-relaxed text-zinc-300"
placeholder="Write your HTML content or markdown here..."
/>
</div>
)}
{/* FEATURES EDITOR */}
{block.block_type === 'features' && (
<div className="grid grid-cols-3 gap-4">
{(block.block_config.items || []).map((item: any, i: number) => (
<div key={i} className="p-4 rounded bg-zinc-950 border border-zinc-800 space-y-2">
<Input
value={item.title}
onChange={e => {
const newItems = [...block.block_config.items];
newItems[i].title = e.target.value;
updateBlock(block.id, { items: newItems });
}}
className="font-bold bg-transparent border-0 p-0 h-auto focus-visible:ring-0"
/>
<Textarea
value={item.desc}
onChange={e => {
const newItems = [...block.block_config.items];
newItems[i].desc = e.target.value;
updateBlock(block.id, { items: newItems });
}}
className="text-xs text-zinc-400 bg-transparent border-0 p-0 h-auto resize-none min-h-[40px] focus-visible:ring-0"
/>
</div>
))}
<Button variant="outline" className="h-full border-dashed border-zinc-800 text-zinc-600" onClick={() => {
const newItems = [...(block.block_config.items || []), { title: 'New Feature', desc: 'Desc' }];
updateBlock(block.id, { items: newItems });
}}>
<Plus className="h-4 w-4" />
</Button>
</div>
)}
</CardContent>
</Card>
))}
{blocks.length === 0 && (
<div className="h-64 flex flex-col items-center justify-center border-2 border-dashed border-zinc-800 rounded-lg text-zinc-600">
<LayoutTemplate className="h-12 w-12 mb-4 opacity-20" />
<p>Page is empty.</p>
<p className="text-sm">Use the sidebar to add blocks.</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import React from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import SitePagesManager from './SitePagesManager';
import NavigationManager from './NavigationManager';
import ThemeSettings from './ThemeSettings';
import { Card } from '@/components/ui/card';
interface SiteDashboardProps {
siteId: string;
}
export default function SiteDashboard({ siteId }: SiteDashboardProps) {
return (
<Tabs defaultValue="pages" className="space-y-6">
<TabsList className="bg-zinc-900 border border-zinc-800">
<TabsTrigger value="pages" className="data-[state=active]:bg-zinc-800">Pages</TabsTrigger>
<TabsTrigger value="navigation" className="data-[state=active]:bg-zinc-800">Navigation</TabsTrigger>
<TabsTrigger value="appearance" className="data-[state=active]:bg-zinc-800">Appearance</TabsTrigger>
<TabsTrigger value="settings" className="data-[state=active]:bg-zinc-800">Settings</TabsTrigger>
</TabsList>
<TabsContent value="pages" className="space-y-4">
<SitePagesManager siteId={siteId} siteDomain="example.com" />
</TabsContent>
<TabsContent value="navigation">
<NavigationManager siteId={siteId} />
</TabsContent>
<TabsContent value="appearance">
<ThemeSettings siteId={siteId} />
</TabsContent>
<TabsContent value="settings">
<div className="text-zinc-500 p-8 border border-dashed border-zinc-800 rounded-lg text-center">
Advanced site settings coming soon in Milestone 5.
</div>
</TabsContent>
</Tabs>
);
}

View File

@@ -0,0 +1,221 @@
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 { Sites as Site } from '@/lib/schemas';
import DomainSetupGuide from '@/components/admin/DomainSetupGuide';
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 result = await client.request(readItem('sites', id));
setSite(result as unknown as Site);
// Merge settings into defaults
if (result.settings) {
setFeatures(prev => ({ ...prev, ...(result.settings as Record<string, any>) }));
}
} 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, {
name: site.name,
url: site.url,
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}
onChange={(e) => setSite({ ...site, name: e.target.value })}
className="bg-slate-900 border-slate-700"
placeholder="My Awesome Site"
/>
<p className="text-xs text-slate-500">Internal identifier for this site</p>
</div>
<div className="space-y-2">
<Label>Domain</Label>
<Input
value={site.url || ''}
onChange={(e) => setSite({ ...site, url: e.target.value })}
className="bg-slate-900 border-slate-700 font-mono text-blue-400"
placeholder="example.com"
/>
<p className="text-xs text-slate-500">Your custom domain (without https://)</p>
</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>
{/* Domain Setup Guide */}
<DomainSetupGuide siteDomain={site.url} />
<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,87 @@
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 { Sites as Site } from '@/lib/schemas';
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 unknown 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 className={site.status === 'active' ? 'bg-green-600' : 'bg-slate-600'}>
{site.status}
</Badge>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white mb-2">{site.url || 'No URL set'}</div>
<p className="text-xs text-slate-500 mb-4">
{site.url ? '🟢 Site configured' : '⚠️ Set up site URL'}
</p>
<div className="mt-4 flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={(e) => {
e.stopPropagation();
window.location.href = `/admin/sites/${site.id}`;
}}
>
Configure
</Button>
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={(e) => {
e.stopPropagation();
if (site.url) {
window.open(`https://${site.url || 'No URL'}`, '_blank');
} else {
alert('Set up a domain first in site settings');
}
}}
>
👁 Preview
</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

@@ -0,0 +1,166 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getDirectusClient, readItems, createItem, deleteItem } from '@/lib/directus/client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { FileText, Plus, Trash2, Edit, ExternalLink } from 'lucide-react';
import { toast } from 'sonner';
const client = getDirectusClient();
interface Page {
id: string;
title: string;
permalink: string;
status: string;
date_updated: string;
}
interface SitePagesManagerProps {
siteId: string;
siteDomain: string;
}
export default function SitePagesManager({ siteId, siteDomain }: SitePagesManagerProps) {
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [newPageTitle, setNewPageTitle] = useState('');
const { data: pages = [], isLoading } = useQuery({
queryKey: ['pages', siteId],
queryFn: async () => {
// @ts-ignore
const res = await client.request(readItems('pages', {
filter: { site: { _eq: siteId } },
sort: ['permalink']
}));
return res as unknown as Page[];
}
});
const createMutation = useMutation({
mutationFn: async () => {
// @ts-ignore
const res = await client.request(createItem('pages', {
title: newPageTitle,
site: siteId, // UUID usually
permalink: `/${newPageTitle.toLowerCase().replace(/ /g, '-')}`,
status: 'draft',
blocks: []
}));
return res;
},
onSuccess: (data: any) => {
queryClient.invalidateQueries({ queryKey: ['pages', siteId] });
toast.success('Page created');
setCreateOpen(false);
setNewPageTitle('');
// Redirect to editor
window.location.href = `/admin/sites/editor/${data.id}`;
},
onError: (e: any) => {
toast.error('Failed to create page: ' + e.message);
}
});
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
// @ts-ignore
await client.request(deleteItem('pages', id));
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pages', siteId] });
toast.success('Page deleted');
}
});
return (
<div className="space-y-6">
<div className="flex justify-between items-center px-1">
<h3 className="text-lg font-medium text-white flex items-center gap-2">
<FileText className="h-5 w-5 text-blue-400" /> Pages
</h3>
<Button onClick={() => setCreateOpen(true)} className="bg-blue-600 hover:bg-blue-500">
<Plus className="mr-2 h-4 w-4" /> New Page
</Button>
</div>
<div className="rounded-md border border-zinc-800 bg-zinc-900/50 overflow-hidden">
<Table>
<TableHeader className="bg-zinc-950">
<TableRow className="border-zinc-800 hover:bg-zinc-950">
<TableHead className="text-zinc-400">Title</TableHead>
<TableHead className="text-zinc-400">Permalink</TableHead>
<TableHead className="text-zinc-400">Status</TableHead>
<TableHead className="text-zinc-400 text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pages.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center text-zinc-500">
No pages yet.
</TableCell>
</TableRow>
) : (
pages.map((page) => (
<TableRow key={page.id} className="border-zinc-800 hover:bg-zinc-900/50">
<TableCell className="font-medium text-white">
{page.title}
</TableCell>
<TableCell className="text-zinc-400 font-mono text-xs">
{page.permalink}
</TableCell>
<TableCell>
<Badge variant="outline" className={page.status === 'published' ? 'bg-green-500/10 text-green-500 border-green-500/20' : 'bg-zinc-500/10 text-zinc-500'}>
{page.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="icon" className="h-8 w-8 text-zinc-400 hover:text-white" onClick={() => window.location.href = `/admin/sites/editor/${page.id}`}>
<Edit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-zinc-400 hover:text-red-500" onClick={() => { if (confirm('Delete page?')) deleteMutation.mutate(page.id); }}>
<Trash2 className="h-4 w-4" />
</Button>
{page.status === 'published' && (
<Button variant="ghost" size="icon" className="h-8 w-8 text-blue-400 hover:text-blue-300" onClick={() => window.open(`https://${siteDomain}${page.permalink}`, '_blank')}>
<ExternalLink className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="bg-zinc-900 border-zinc-800 text-white">
<DialogHeader>
<DialogTitle>Create New Page</DialogTitle>
</DialogHeader>
<div className="py-4">
<label className="text-xs uppercase font-bold text-zinc-500 mb-2 block">Page Title</label>
<Input
value={newPageTitle}
onChange={e => setNewPageTitle(e.target.value)}
placeholder="e.g. About Us"
className="bg-zinc-950 border-zinc-800"
/>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button onClick={() => createMutation.mutate()} disabled={!newPageTitle} className="bg-blue-600 hover:bg-blue-500">Create & Edit</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,178 @@
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getDirectusClient, readItems, createItem, updateItem, deleteItem } from '@/lib/directus/client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '@/components/ui/card';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogTrigger
} from '@/components/ui/dialog';
import { Globe, Plus, Settings, Trash2, ExternalLink } from 'lucide-react';
import { toast } from 'sonner';
const client = getDirectusClient();
interface Site {
id: string;
name: string;
url: string;
status: 'active' | 'inactive';
settings?: any;
}
export default function SitesManager() {
const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false);
const [editingSite, setEditingSite] = useState<Partial<Site>>({});
// Fetch
const { data: sites = [], isLoading } = useQuery({
queryKey: ['sites'],
queryFn: async () => {
// @ts-ignore
const res = await client.request(readItems('sites', { limit: -1 }));
return res as unknown as Site[];
}
});
// Mutations
const mutation = useMutation({
mutationFn: async (site: Partial<Site>) => {
if (site.id) {
// @ts-ignore
await client.request(updateItem('sites', site.id, site));
} else {
// @ts-ignore
await client.request(createItem('sites', site));
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sites'] });
toast.success(editingSite.id ? 'Site updated' : 'Site created');
setEditorOpen(false);
}
});
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
// @ts-ignore
await client.request(deleteItem('sites', id));
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sites'] });
toast.success('Site deleted');
}
});
return (
<div className="space-y-6">
<div className="flex justify-between items-center bg-zinc-900/50 p-6 rounded-lg border border-zinc-800 backdrop-blur-sm">
<div>
<h2 className="text-xl font-bold text-white">Your Sites</h2>
<p className="text-zinc-400 text-sm">Manage your deployed web properties.</p>
</div>
<Button className="bg-blue-600 hover:bg-blue-500" onClick={() => { setEditingSite({}); setEditorOpen(true); }}>
<Plus className="mr-2 h-4 w-4" /> Add Site
</Button>
</div>
<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-zinc-900 border-zinc-800 hover:border-zinc-700 transition-colors group">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium text-zinc-200">
{site.name}
</CardTitle>
<Badge variant={site.status === 'active' ? 'default' : 'secondary'} className={site.status === 'active' ? 'bg-green-500/10 text-green-500 hover:bg-green-500/20' : ''}>
{site.status || 'inactive'}
</Badge>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold truncate text-white tracking-tight">{site.url}</div>
<p className="text-xs text-zinc-500 mt-1 flex items-center">
<Globe className="h-3 w-3 mr-1" />
deployed via Launchpad
</p>
</CardContent>
<CardFooter className="flex justify-between border-t border-zinc-800 pt-4">
<Button variant="ghost" size="sm" className="text-zinc-400 hover:text-white" onClick={() => window.open(`https://${site.url}`, '_blank')}>
<ExternalLink className="h-4 w-4 mr-2" /> Visit
</Button>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="bg-zinc-800 border-zinc-700 hover:bg-zinc-700 text-zinc-300" onClick={() => window.location.href = `/admin/sites/${site.id}`}>
Manage Content
</Button>
<Button variant="outline" size="sm" className="bg-purple-900/30 border-purple-700 hover:bg-purple-800/40 text-purple-300" onClick={() => window.open(`/preview/site/${site.id}`, '_blank')}>
👁 Preview
</Button>
</div>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-zinc-400 hover:text-white" onClick={() => { setEditingSite(site); setEditorOpen(true); }}>
<Settings className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-zinc-400 hover:text-red-500" onClick={() => { if (confirm('Delete site?')) deleteMutation.mutate(site.id); }}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardFooter>
</Card>
))}
{sites.length === 0 && (
<div className="col-span-full h-64 flex flex-col items-center justify-center border-2 border-dashed border-zinc-800 rounded-lg text-zinc-500">
<Globe className="h-10 w-10 mb-4 opacity-20" />
<p>No sites configured yet.</p>
<Button variant="link" onClick={() => { setEditingSite({}); setEditorOpen(true); }}>Create your first site</Button>
</div>
)}
</div>
<Dialog open={editorOpen} onOpenChange={setEditorOpen}>
<DialogContent className="bg-zinc-900 border-zinc-800 text-white">
<DialogHeader>
<DialogTitle>{editingSite.id ? 'Edit Site' : 'New Site'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-xs uppercase font-bold text-zinc-500">Site Name</label>
<Input
value={editingSite.name || ''}
onChange={e => setEditingSite({ ...editingSite, name: e.target.value })}
placeholder="My Awesome Blog"
className="bg-zinc-950 border-zinc-800"
/>
</div>
<div className="space-y-2">
<label className="text-xs uppercase font-bold text-zinc-500">Domain</label>
<div className="flex">
<span className="inline-flex items-center px-3 rounded-l-md border border-r-0 border-zinc-800 bg-zinc-900 text-zinc-500 text-sm">https://</span>
<Input
value={editingSite.url || ''}
onChange={e => setEditingSite({ ...editingSite, url: e.target.value })}
placeholder="example.com"
className="rounded-l-none bg-zinc-950 border-zinc-800"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs uppercase font-bold text-zinc-500">Status</label>
<select
className="flex h-9 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-1 text-sm shadow-sm transition-colors text-white"
value={editingSite.status || 'active'}
onChange={e => setEditingSite({ ...editingSite, status: e.target.value as any })}
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => setEditorOpen(false)}>Cancel</Button>
<Button onClick={() => mutation.mutate(editingSite)} className="bg-blue-600 hover:bg-blue-500">Save Site</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,116 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getDirectusClient, readItems, createItem, updateItem } from '@/lib/directus/client';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Save, Palette, Type } from 'lucide-react';
import { toast } from 'sonner';
const client = getDirectusClient();
interface GlobalSettings {
id?: string;
site: string;
primary_color: string;
secondary_color: string;
footer_text: string;
}
interface ThemeSettingsProps {
siteId: string;
}
export default function ThemeSettings({ siteId }: ThemeSettingsProps) {
const queryClient = useQueryClient();
const [settings, setSettings] = useState<Partial<GlobalSettings>>({});
const { data: globalRecord, isLoading } = useQuery({
queryKey: ['globals', siteId],
queryFn: async () => {
// @ts-ignore
const res = await client.request(readItems('globals', {
filter: { site: { _eq: siteId } },
limit: 1
}));
const record = res[0] as GlobalSettings;
if (record) setSettings(record);
return record;
}
});
const saveMutation = useMutation({
mutationFn: async () => {
if (globalRecord?.id) {
// @ts-ignore
await client.request(updateItem('globals', globalRecord.id, settings));
} else {
// @ts-ignore
await client.request(createItem('globals', { ...settings, site: siteId }));
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['globals', siteId] });
toast.success('Theme settings saved');
}
});
if (isLoading) return <div className="text-zinc-500">Loading settings...</div>;
return (
<div className="max-w-2xl space-y-8">
<div className="space-y-4">
<h3 className="text-lg font-medium text-white flex items-center gap-2">
<Palette className="h-5 w-5 text-purple-400" /> Colors
</h3>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-xs uppercase font-bold text-zinc-500">Primary Color</label>
<div className="flex gap-2">
<div className="w-10 h-10 rounded border border-zinc-700" style={{ backgroundColor: settings.primary_color || '#000000' }}></div>
<Input
value={settings.primary_color || ''}
onChange={e => setSettings({ ...settings, primary_color: e.target.value })}
placeholder="#000000"
className="bg-zinc-950 border-zinc-800 font-mono"
/>
</div>
</div>
<div className="space-y-2">
<label className="text-xs uppercase font-bold text-zinc-500">Secondary Color</label>
<div className="flex gap-2">
<div className="w-10 h-10 rounded border border-zinc-700" style={{ backgroundColor: settings.secondary_color || '#ffffff' }}></div>
<Input
value={settings.secondary_color || ''}
onChange={e => setSettings({ ...settings, secondary_color: e.target.value })}
placeholder="#ffffff"
className="bg-zinc-950 border-zinc-800 font-mono"
/>
</div>
</div>
</div>
</div>
<div className="space-y-4 pt-4 border-t border-zinc-800">
<h3 className="text-lg font-medium text-white flex items-center gap-2">
<Type className="h-5 w-5 text-blue-400" /> Typography & Text
</h3>
<div className="space-y-2">
<label className="text-xs uppercase font-bold text-zinc-500">Footer Text</label>
<Textarea
value={settings.footer_text || ''}
onChange={e => setSettings({ ...settings, footer_text: e.target.value })}
className="bg-zinc-950 border-zinc-800 min-h-[100px]"
placeholder="© 2024 My Company. All rights reserved."
/>
</div>
</div>
<div className="pt-6">
<Button onClick={() => saveMutation.mutate()} className="bg-blue-600 hover:bg-blue-500 w-full md:w-auto">
<Save className="mr-2 h-4 w-4" /> Save Changes
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,178 @@
import React, { useEffect, useState } from 'react';
import { useStore } from '@nanostores/react';
import { debugIsOpen, activeTab, logs, type LogEntry } from '../../stores/debugStore';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { getDirectusClient } from '@/lib/directus/client';
// Create a client for the devtools if one doesn't exist in context
// (Ideally this component is inside the main QueryClientProvider, but we'll see)
const queryClient = new QueryClient();
export default function DebugToolbar() {
const isOpen = useStore(debugIsOpen);
const currentTab = useStore(activeTab);
const logEntries = useStore(logs);
const [backendStatus, setBackendStatus] = useState<'checking' | 'online' | 'error'>('checking');
const [latency, setLatency] = useState<number | null>(null);
useEffect(() => {
if (isOpen && currentTab === 'backend') {
checkBackend();
}
}, [isOpen, currentTab]);
const checkBackend = async () => {
setBackendStatus('checking');
const start = performance.now();
try {
const client = getDirectusClient();
await client.request(() => ({
path: '/server/ping',
method: 'GET'
}));
setLatency(Math.round(performance.now() - start));
setBackendStatus('online');
} catch (e) {
setBackendStatus('error');
}
};
if (!isOpen) {
return (
<button
onClick={() => debugIsOpen.set(true)}
className="fixed bottom-4 right-4 z-[9999] p-3 bg-black text-white rounded-full shadow-2xl hover:scale-110 transition-transform border border-gray-700"
title="Open Debug Toolbar"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
</button>
);
}
return (
<div className="fixed bottom-0 left-0 right-0 h-[33vh] z-[9999] bg-black/95 text-white border-t border-gray-800 shadow-[0_-4px_20px_rgba(0,0,0,0.5)] flex flex-col font-mono text-sm backdrop-blur">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800 bg-gray-900/50">
<div className="flex items-center gap-4">
<span className="font-bold text-yellow-500"> Spark Debug</span>
<div className="flex gap-1 bg-gray-800 rounded p-1">
{(['console', 'backend', 'network'] as const).map(tab => (
<button
key={tab}
onClick={() => activeTab.set(tab)}
className={`px-3 py-1 rounded text-xs uppercase font-medium transition-colors ${currentTab === tab
? 'bg-gray-700 text-white'
: 'text-gray-400 hover:text-white'
}`}
>
{tab}
</button>
))}
</div>
</div>
<button
onClick={() => debugIsOpen.set(false)}
className="p-1 hover:bg-gray-800 rounded"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden relative">
{/* Console Tab */}
{currentTab === 'console' && (
<div className="h-full overflow-y-auto p-4 space-y-1">
{logEntries.length === 0 && (
<div className="text-gray-500 text-center mt-10">No logs captured yet...</div>
)}
{logEntries.map((log) => (
<div key={log.id} className="flex gap-2 font-mono text-xs border-b border-gray-800/50 pb-1">
<span className="text-gray-500 shrink-0">[{log.timestamp}]</span>
<span className={`shrink-0 w-12 font-bold uppercase ${log.type === 'error' ? 'text-red-500' :
log.type === 'warn' ? 'text-yellow-500' :
'text-blue-400'
}`}>
{log.type}
</span>
<span className="text-gray-300 break-all">
{log.messages.join(' ')}
</span>
</div>
))}
<div className="absolute bottom-4 right-4">
<button
onClick={() => logs.set([])}
className="px-2 py-1 bg-gray-800 text-xs rounded hover:bg-gray-700"
>
Clear
</button>
</div>
</div>
)}
{/* Backend Tab */}
{currentTab === 'backend' && (
<div className="h-full p-6 flex flex-col items-center justify-center gap-4">
<div className={`text-4xl ${backendStatus === 'online' ? 'text-green-500' :
backendStatus === 'error' ? 'text-red-500' :
'text-yellow-500 animate-pulse'
}`}>
{backendStatus === 'online' ? '● Online' :
backendStatus === 'error' ? '✖ Error' : '● Checking...'}
</div>
<div className="text-center space-y-2">
<p className="text-gray-400">
Directus URL: <span className="text-white">{import.meta.env.PUBLIC_DIRECTUS_URL}</span>
</p>
{latency && (
<p className="text-gray-400">
Latency: <span className="text-white">{latency}ms</span>
</p>
)}
</div>
<button
onClick={checkBackend}
className="px-4 py-2 bg-gray-800 rounded hover:bg-gray-700 transition"
>
Re-check Connection
</button>
</div>
)}
{/* Network / React Query Tab */}
{currentTab === 'network' && (
<div className="h-full w-full relative bg-gray-900">
<div className="absolute inset-0 flex items-center justify-center text-gray-500">
{/*
React Query Devtools needs a QueryClientProvider context.
In Astro, components are islands. If this island doesn't share context with the main app
(which it likely won't if they are separate roots), we might see empty devtools.
However, putting it here is the best attempt.
*/}
<div className="text-center">
<p className="mb-2">React Query Devtools</p>
<p className="text-xs">
(If empty, data fetching might be happening Server-Side or in a different Context)
</p>
</div>
</div>
{/* We force mount devtools panel here if possible */}
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={true} />
</QueryClientProvider>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import Hero from './blocks/Hero';
import Content from './blocks/Content';
import Features from './blocks/Features';
interface Block {
id: string;
block_type: string;
block_config: any;
}
interface BlockRendererProps {
blocks: Block[];
}
export default function BlockRenderer({ blocks }: BlockRendererProps) {
if (!blocks || !Array.isArray(blocks)) return null;
return (
<div className="flex flex-col">
{blocks.map(block => {
switch (block.block_type) {
case 'hero':
return <Hero key={block.id} {...block.block_config} />;
case 'content':
return <Content key={block.id} {...block.block_config} />;
case 'features':
return <Features key={block.id} {...block.block_config} />;
case 'cta':
// reuse Hero styled as CTA or simple banner
return <Hero key={block.id} {...block.block_config} bg="dark" />;
default:
console.warn(`Unknown block type: ${block.block_type}`);
return null;
}
})}
</div>
);
}

View File

@@ -0,0 +1,13 @@
import React from 'react';
interface ContentProps {
content: string;
}
export default function Content({ content }: ContentProps) {
return (
<section className="py-12 px-8">
<div className="prose prose-lg dark:prose-invert mx-auto" dangerouslySetInnerHTML={{ __html: content }} />
</section>
);
}

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { CheckCircle2 } from 'lucide-react';
interface FeatureItem {
title: string;
desc: string;
icon?: string;
}
interface FeaturesProps {
items: FeatureItem[];
layout?: 'grid' | 'list';
}
export default function Features({ items, layout = 'grid' }: FeaturesProps) {
return (
<section className="py-16 px-8 bg-zinc-50 dark:bg-zinc-900/50">
<div className="max-w-6xl mx-auto">
<div className={`grid gap-8 ${layout === 'list' ? 'grid-cols-1' : 'grid-cols-1 md:grid-cols-3'}`}>
{items?.map((item, i) => (
<Card key={i} className="border-0 shadow-lg bg-white dark:bg-zinc-900 dark:border-zinc-800">
<CardHeader className="pb-2">
<div className="w-12 h-12 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center mb-4 text-blue-600 dark:text-blue-400">
<CheckCircle2 className="h-6 w-6" />
</div>
<CardTitle className="text-xl">{item.title}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-zinc-600 dark:text-zinc-400 leading-relaxed">
{item.desc}
</p>
</CardContent>
</Card>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,38 @@
import React from 'react';
import { Button } from '@/components/ui/button';
interface HeroProps {
title: string;
subtitle?: string;
bg?: string;
ctaLabel?: string;
ctaUrl?: string;
}
export default function Hero({ title, subtitle, bg, ctaLabel, ctaUrl }: HeroProps) {
const bgClass = bg === 'dark' ? 'bg-zinc-900 text-white' :
bg === 'image' ? 'bg-zinc-800 text-white' : // Placeholder for image logic
'bg-white text-zinc-900';
return (
<section className={`py-20 px-8 text-center ${bgClass}`}>
<div className="max-w-4xl mx-auto space-y-6">
<h1 className="text-5xl md:text-6xl font-extrabold tracking-tight">
{title}
</h1>
{subtitle && (
<p className="text-xl md:text-2xl opacity-80 max-w-2xl mx-auto">
{subtitle}
</p>
)}
{(ctaLabel && ctaUrl) && (
<div className="pt-4">
<Button asChild size="lg" className="text-lg px-8 py-6 rounded-full">
<a href={ctaUrl}>{ctaLabel}</a>
</Button>
</div>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { Toaster as SonnerToaster } from 'sonner';
import { queryClient } from '@/lib/react-query';
interface CoreProviderProps {
children: React.ReactNode;
}
export const CoreProvider = ({ children }: CoreProviderProps) => {
return (
<QueryClientProvider client={queryClient}>
{children}
<SonnerToaster position="top-right" theme="system" richColors closeButton />
</QueryClientProvider>
);
};
export const GlobalToaster = () => {
return <SonnerToaster position="top-right" theme="system" richColors closeButton />;
}

View File

View File

View File

View File

View File

@@ -0,0 +1,109 @@
import React, { useState } from 'react';
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { CheckCircle2, AlertTriangle, XCircle, Search, FileText } from 'lucide-react';
// We import the analysis functions directly since this is a client component in Astro/React
import { analyzeSeo, analyzeReadability } from '@/lib/testing/seo';
const TestRunner = () => {
const [content, setContent] = useState('');
const [keyword, setKeyword] = useState('');
const [results, setResults] = useState<any>(null);
const runTests = () => {
const seo = analyzeSeo(content, keyword);
const read = analyzeReadability(content);
setResults({ seo, read });
};
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 h-[calc(100vh-140px)]">
{/* Input Column */}
<div className="flex flex-col gap-4">
<Card className="p-4 space-y-4 bg-card/50 backdrop-blur">
<h3 className="font-semibold text-sm flex items-center gap-2">
<FileText className="h-4 w-4" /> Content Source
</h3>
<div className="space-y-2">
<Input
placeholder="Target Keyword"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<Textarea
className="min-h-[400px] font-mono text-sm"
placeholder="Paste content here to analyze..."
value={content}
onChange={(e) => setContent(e.target.value)}
/>
</div>
<Button onClick={runTests} className="w-full">
<Search className="h-4 w-4 mr-2" /> Run Analysis
</Button>
</Card>
</div>
{/* Results Column */}
<div className="flex flex-col gap-4 overflow-y-auto">
{results ? (
<>
<Card className="p-6 bg-card/50 backdrop-blur space-y-6">
<div>
<div className="flex justify-between mb-2">
<h3 className="font-semibold">SEO Score</h3>
<span className={`font-bold ${results.seo.score >= 80 ? 'text-green-500' : 'text-yellow-500'}`}>
{results.seo.score}/100
</span>
</div>
<Progress value={results.seo.score} className="h-2" />
<div className="mt-4 space-y-2">
{results.seo.issues.length === 0 && (
<div className="flex items-center gap-2 text-green-500 text-sm">
<CheckCircle2 className="h-4 w-4" /> No issues found!
</div>
)}
{results.seo.issues.map((issue: string, i: number) => (
<div key={i} className="flex items-start gap-2 text-yellow-500 text-sm">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
<span>{issue}</span>
</div>
))}
</div>
</div>
<div className="pt-6 border-t border-border/50">
<div className="flex justify-between mb-2">
<h3 className="font-semibold">Readability</h3>
<span className="text-muted-foreground text-sm">{results.read.feedback}</span>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="p-3 bg-background/50 rounded border border-border/50 text-center">
<div className="text-2xl font-bold">{results.read.gradeLevel}</div>
<div className="text-xs text-muted-foreground">Grade Level</div>
</div>
<div className="p-3 bg-background/50 rounded border border-border/50 text-center">
<div className="text-2xl font-bold">{results.read.score}</div>
<div className="text-xs text-muted-foreground">Flow Score</div>
</div>
</div>
</div>
</Card>
</>
) : (
<Card className="flex-1 flex flex-col items-center justify-center p-8 text-muted-foreground opacity-50 border-dashed">
<Search className="h-12 w-12 mb-4" />
<p>No results yet. Run analysis to see scores.</p>
</Card>
)}
</div>
</div>
);
};
export default TestRunner;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { Construction } from 'lucide-react';
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
interface UnderConstructionProps {
title: string;
description?: string;
eta?: string;
}
const UnderConstruction = ({ title, description = "This module is currently being built.", eta = "Coming Soon" }: UnderConstructionProps) => {
return (
<Card className="border-dashed border-2 border-border/50 bg-card/20 backdrop-blur-sm h-[400px] flex flex-col items-center justify-center text-center p-8">
<div className="p-4 rounded-full bg-primary/10 mb-6 animate-pulse">
<Construction className="h-12 w-12 text-primary" />
</div>
<h2 className="text-2xl font-bold mb-2">{title}</h2>
<p className="text-muted-foreground max-w-md mb-6">
{description}
</p>
<Badge variant="outline" className="px-4 py-1 border-primary/20 text-primary">
{eta}
</Badge>
</Card>
);
};
export default UnderConstruction;

View File

@@ -0,0 +1,43 @@
import * as React from "react"
const AlertDialog = ({ open, onOpenChange, children }: any) => {
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={() => onOpenChange(false)} />
<div className="relative z-50">{children}</div>
</div>
)
}
const AlertDialogContent = ({ children, className }: any) => (
<div className={`bg-slate-800 rounded-lg shadow-lg max-w-md w-full p-6 ${className}`}>
{children}
</div>
)
const AlertDialogHeader = ({ children }: any) => <div className="mb-4">{children}</div>
const AlertDialogTitle = ({ children, className }: any) => <h2 className={`text-xl font-bold ${className}`}>{children}</h2>
const AlertDialogDescription = ({ children, className }: any) => <p className={`text-sm text-slate-400 ${className}`}>{children}</p>
const AlertDialogFooter = ({ children }: any) => <div className="mt-6 flex justify-end gap-2">{children}</div>
const AlertDialogAction = ({ children, onClick, disabled, className }: any) => (
<button onClick={onClick} disabled={disabled} className={`px-4 py-2 rounded ${className}`}>
{children}
</button>
)
const AlertDialogCancel = ({ children, disabled, className }: any) => (
<button disabled={disabled} className={`px-4 py-2 rounded ${className}`}>
{children}
</button>
)
export {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,35 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> { }
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,55 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,78 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, ...props }, ref) => (
<input
type="checkbox"
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
ref={ref}
{...props}
/>
)
)
Checkbox.displayName = "Checkbox"
export { Checkbox }

View File

@@ -0,0 +1,31 @@
import * as React from "react"
const Dialog = ({ open, onOpenChange, children }: any) => {
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-24">
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm" onClick={() => onOpenChange(false)} />
<div className="relative z-50 animate-in fade-in zoom-in-95 duration-200">{children}</div>
</div>
)
}
const DialogTrigger = ({ children, asChild, onClick, ...props }: any) => {
// This is a simplified trigger that just renders children.
// In a real implementation (Radix UI), this controls the dialog state.
// For now, we rely on the parent controlling 'open' state.
return <div onClick={onClick} {...props}>{children}</div>
}
const DialogContent = ({ children, className }: any) => (
<div className={`bg-zinc-900 border border-zinc-800 rounded-lg shadow-2xl max-w-lg w-full p-6 ${className}`}>
{children}
</div>
)
const DialogHeader = ({ children }: any) => <div className="mb-4 text-left">{children}</div>
const DialogTitle = ({ children, className }: any) => <h2 className={`text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-white to-zinc-400 ${className}`}>{children}</h2>
const DialogDescription = ({ children, className }: any) => <p className={`text-sm text-zinc-400 ${className}`}>{children}</p>
const DialogFooter = ({ children }: any) => <div className="mt-6 flex justify-end gap-2">{children}</div>
export { Dialog, DialogContent, DialogTrigger, DialogHeader, DialogTitle, DialogDescription, DialogFooter }

View File

@@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,12 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Label = React.forwardRef<HTMLLabelElement, React.LabelHTMLAttributes<HTMLLabelElement>>(
({ className, ...props }, ref) => (
<label ref={ref} className={cn("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", className)} {...props} />
)
)
Label.displayName = "Label"
export { Label }

View File

@@ -0,0 +1,14 @@
import * as React from "react"
import { cn } from "@/lib/utils"
// Simplified Progress without radix for speed, or if radix is missing
const Progress = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & { value?: number }>(
({ className, value, ...props }, ref) => (
<div ref={ref} className={cn("relative h-4 w-full overflow-hidden rounded-full bg-secondary", className)} {...props}>
<div className="h-full w-full flex-1 bg-primary transition-all" style={{ transform: `translateX(-${100 - (value || 0)}%)` }} />
</div>
)
)
Progress.displayName = "Progress"
export { Progress }

View File

@@ -0,0 +1,20 @@
import * as React from "react"
const Select = ({ children, value, onValueChange }: any) => {
return (
<select
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{children}
</select>
)
}
const SelectTrigger = ({ children, className }: any) => <>{children}</>
const SelectValue = ({ placeholder }: any) => <option value="">{placeholder}</option>
const SelectContent = ({ children, className }: any) => <>{children}</>
const SelectItem = ({ value, children }: any) => <option value={value}>{children}</option>
export { Select, SelectTrigger, SelectValue, SelectContent, SelectItem }

View File

@@ -0,0 +1,20 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, ...props }, ref) => (
<input
type="range"
className={cn(
"w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700",
className
)}
ref={ref}
{...props}
/>
)
)
Slider.displayName = "Slider"
export { Slider }

View File

@@ -0,0 +1,22 @@
import { cn } from "@/lib/utils"
function Spinner({ className, size = "default" }: { className?: string; size?: "sm" | "default" | "lg" }) {
const sizeClasses = {
sm: "h-4 w-4",
default: "h-8 w-8",
lg: "h-12 w-12"
}
return (
<div className={cn("flex items-center justify-center", className)}>
<div
className={cn(
"animate-spin rounded-full border-2 border-current border-t-transparent text-primary",
sizeClasses[size]
)}
/>
</div>
)
}
export { Spinner }

View File

@@ -0,0 +1,33 @@
import * as React from "react"
import type { Primitive } from "@radix-ui/react-primitive"
// Simplified Switch to avoid Radix dependency issues if not installed, or use standard div toggle
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement> & { checked?: boolean, onCheckedChange?: (checked: boolean) => void }>(
({ className, checked, onCheckedChange, ...props }, ref) => (
<button
type="button"
role="switch"
aria-checked={checked}
ref={ref}
onClick={() => onCheckedChange?.(!checked)}
className={cn(
"peer inline-flex h-[24px] w-[44px] shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
checked ? "bg-primary" : "bg-slate-700",
className
)}
{...props}
>
<span
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
checked ? "translate-x-5 bg-white" : "translate-x-0 bg-slate-400"
)}
/>
</button>
)
)
Switch.displayName = "Switch"
export { Switch }

119
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,119 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,52 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,19 @@
import * as React from "react"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { }
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={`flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 ${className}`}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

1
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference path="../.astro/types.d.ts" />

View File

@@ -0,0 +1,238 @@
---
interface Props {
title: string;
}
const { title } = Astro.props;
const currentPath = Astro.url.pathname;
import SystemStatus from '@/components/admin/SystemStatus';
import SystemStatusBar from '@/components/admin/SystemStatusBar';
import { GlobalToaster, CoreProvider } from '@/components/providers/CoreProviders';
const navGroups = [
{
title: 'Command Station',
items: [
{ href: '/admin', label: 'Mission Control', icon: 'home' },
{ href: '/admin/sites/jumpstart', label: 'Jumpstart Test 🚀', icon: 'rocket_launch' },
{ href: '/admin/content-factory', label: 'Content Factory', icon: 'factory' },
]
},
{
title: 'Intelligence Library',
items: [
{ href: '/admin/content/avatars', label: 'Avatar Intelligence', icon: 'users' },
{ href: '/admin/collections/avatar-variants', label: 'Avatar Variants', icon: 'users' },
{ href: '/admin/collections/geo-intelligence', label: 'Geo Intelligence', icon: 'map' },
{ href: '/admin/collections/spintax-dictionaries', label: 'Spintax Dictionaries', icon: 'puzzle' },
{ href: '/admin/collections/cartesian-patterns', label: 'Cartesian Patterns', icon: 'hub' },
]
},
{
title: 'Content Engine',
items: [
{ href: '/admin/collections/campaign-masters', label: 'Campaigns', icon: 'web' },
{ href: '/admin/collections/content-fragments', label: 'Content Fragments', icon: 'puzzle' },
{ href: '/admin/collections/headline-inventory', label: 'Headlines', icon: 'puzzle' },
{ href: '/admin/collections/offer-blocks', label: 'Offer Blocks', icon: 'puzzle' },
{ href: '/admin/collections/generation-jobs', label: 'Generation Queue', icon: 'history' },
]
},
{
title: 'Production',
items: [
{ href: '/admin/sites', label: 'Sites & Deployments', icon: 'web' },
{ href: '/admin/seo/articles', label: 'Generated Articles', icon: 'newspaper' },
{ href: '/admin/leads', label: 'Leads & Inquiries', icon: 'users' },
{ href: '/admin/media/templates', label: 'Media Assets', icon: 'image' },
]
},
{
title: 'System',
items: [
{ href: '/admin/settings', label: 'Configuration', icon: 'settings' },
{ href: '/admin/content/work_log', label: 'System Logs', icon: 'history' },
]
}
];
function isActive(href: string) {
if (href === '/admin') return currentPath === '/admin';
return currentPath.startsWith(href);
}
---
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title} | Spark Admin</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<style is:global>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
:root {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
--radius: 0.5rem;
}
* {
border-color: hsl(var(--border));
}
body {
font-family: 'Inter', sans-serif;
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
</style>
</head>
<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 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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<span class="text-xl font-bold text-white">Spark Admin</span>
</a>
</div>
<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 === 'rocket_launch' && (
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.59 14.37a6 6 0 01-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 006.16-12.12A14.98 14.98 0 009.631 8.41m5.96 5.96a14.926 14.926 0 01-5.841 2.58m-.119-8.54a6 6 0 00-7.381 5.84h4.8m2.581-5.84a14.927 14.927 0 00-2.58 5.84m2.699 2.7c-.103.021-.207.041-.311.06a15.09 15.09 0 01-2.448-2.448 14.9 14.9 0 01.06-.312m-2.24 2.39a4.493 4.493 0 00-1.757 4.306 4.493 4.493 0 004.306-1.758M16.5 9a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0z" /></svg>
)}
{item.icon === 'factory' && (
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m8-2a2 2 0 00-2-2H9a2 2 0 00-2 2v2m7-2a2 2 0 00-2-2h-1a2 2 0 00-2 2v2m-6-2a2 2 0 00-2-2h-1a2 2 0 00-2 2v2" /></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 === '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 === '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 === 'hub' && (
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>
)}
{item.icon === 'web' && (
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></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 === '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 === '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>
)}
{item.icon === 'history' && (
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
)}
</span>
<span class="font-medium">{item.label}</span>
</a>
))}
</div>
</div>
))}
</nav>
<div class="px-4 pb-4 mt-auto">
<SystemStatus client:load />
</div>
<div class="p-4 border-t border-gray-800">
<a
href="/"
target="_blank"
class="flex items-center gap-3 px-4 py-3 rounded-lg text-gray-400 hover:bg-gray-800 hover:text-white transition-colors"
>
<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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
<span class="font-medium">View Site</span>
</a>
</div>
</aside>
<!-- Main Content -->
<div class="flex-1 ml-64">
<header class="sticky top-0 z-40 bg-gray-900/80 backdrop-blur-lg border-b border-gray-800 px-8 py-4">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-white">{title}</h1>
<div class="flex items-center gap-4">
<button class="p-2 text-gray-400 hover:text-white rounded-lg hover:bg-gray-800 transition-colors">
<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="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
</button>
<div class="w-10 h-10 bg-gradient-to-br from-purple-500 to-pink-500 rounded-full flex items-center justify-center text-white font-semibold">
A
</div>
</div>
</div>
</header>
<main class="p-8 pb-24">
<CoreProvider client:load>
<slot />
</CoreProvider>
</main>
</div>
<!-- Full-Width System Status Bar -->
<SystemStatusBar client:load />
<GlobalToaster client:load />
</body>
</html>

View File

View File

44
src/lib/assembler/data.ts Normal file
View File

@@ -0,0 +1,44 @@
import { directus } from '@/lib/directus/client';
import { readItems } from '@directus/sdk';
/**
* Fetches all spintax dictionaries and flattens them into a usable SpintaxMap.
* Returns: { "adjective": "{great|good|awesome}", "noun": "{cat|dog}" }
*/
export async function fetchSpintaxMap(): Promise<Record<string, string>> {
try {
const items = await directus.request(
readItems('spintax_dictionaries', {
fields: ['category', 'variations'],
limit: -1
})
);
const map: Record<string, string> = {};
items.forEach((item: any) => {
if (item.category && item.variations) {
// Example: category="premium", variations="{high-end|luxury|top-tier}"
map[item.category] = item.variations;
}
});
return map;
} catch (error) {
console.error('Error fetching spintax:', error);
return {};
}
}
/**
* Saves a new pattern (template) to the database.
*/
export async function savePattern(patternName: string, structure: string) {
// Assuming 'cartesian_patterns' is where we store templates
// or we might need a dedicated 'templates' collection if structure differs.
// For now using 'cartesian_patterns' as per config.
// Implementation pending generic createItem helper or direct SDK usage
// This will be called by the API endpoint.
}

View File

@@ -0,0 +1,68 @@
/**
* Spintax Processing Engine
* Handles nested spintax formats: {option1|option2|{nested1|nested2}}
*/
export function processSpintax(text: string): string {
if (!text) return '';
// Regex to find the innermost spintax group { ... }
const spintaxRegex = /\{([^{}]*)\}/;
let processedText = text;
let match = spintaxRegex.exec(processedText);
// Keep processing until no more spintax groups are found
while (match) {
const fullMatch = match[0]; // e.g., "{option1|option2}"
const content = match[1]; // e.g., "option1|option2"
const options = content.split('|');
const randomOption = options[Math.floor(Math.random() * options.length)];
processedText = processedText.replace(fullMatch, randomOption);
// Re-check for remaining matches (including newly exposed or remaining groups)
match = spintaxRegex.exec(processedText);
}
return processedText;
}
/**
* Variable Substitution Engine
* Replaces {{variable_name}} with provided values.
* Supports fallback values: {{variable_name|default_value}}
*/
export function processVariables(text: string, variables: Record<string, string>): string {
if (!text) return '';
return text.replace(/\{\{([^}]+)\}\}/g, (match, variableKey) => {
// Check for default value syntax: {{city|New York}}
const [key, defaultValue] = variableKey.split('|');
const cleanKey = key.trim();
const value = variables[cleanKey];
if (value !== undefined && value !== null && value !== '') {
return value;
}
return defaultValue ? defaultValue.trim() : match; // Return original if no match and no default
});
}
/**
* Master Assembly Function
* Runs spintax first, then variable substitution.
*/
export function assembleContent(template: string, variables: Record<string, string>): string {
// 1. Process Spintax (Randomize structure)
const spunContent = processSpintax(template);
// 2. Substitute Variables (Inject specific data)
const finalContent = processVariables(spunContent, variables);
return finalContent;
}

View File

0
src/lib/assembler/seo.ts Normal file
View File

View File

View File

View File

@@ -0,0 +1,214 @@
// @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.
* @param overrides Optional overrides for slug, title, etc.
*/
async generateArticle(context: GenerationContext, overrides?: any) {
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 (Skipped for MVP)
// MERGE
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 = overrides?.title || this.generateMetaTitle(context, variant);
return {
title: metaTitle,
html_content: html,
slug: overrides?.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,107 @@
/**
* Collection Page Template Generator
* Creates standardized CRUD pages for all collections
*/
export const collectionConfigs = {
avatar_intelligence: {
title: 'Avatar Intelligence',
description: 'Manage persona profiles and variants',
icon: '👥',
fields: ['base_name', 'wealth_cluster', 'business_niches'],
displayField: 'base_name',
},
avatar_variants: {
title: 'Avatar Variants',
description: 'Manage gender and tone variations',
icon: '🎭',
fields: ['avatar_id', 'variant_name', 'pronouns'],
displayField: 'variant_name',
},
campaign_masters: {
title: 'Campaign Masters',
description: 'Manage marketing campaigns',
icon: '📢',
fields: ['campaign_name', 'status', 'site_id'],
displayField: 'campaign_name',
},
cartesian_patterns: {
title: 'Cartesian Patterns',
description: 'Content structure templates',
icon: '🔧',
fields: ['pattern_name', 'structure_type'],
displayField: 'pattern_name',
},
content_fragments: {
title: 'Content Fragments',
description: 'Reusable content blocks',
icon: '📦',
fields: ['fragment_type', 'content'],
displayField: 'fragment_type',
},
generated_articles: {
title: 'Generated Articles',
description: 'AI-generated content output',
icon: '📝',
fields: ['title', 'status', 'seo_score', 'geo_city'],
displayField: 'title',
},
generation_jobs: {
title: 'Generation Jobs',
description: 'Content generation queue',
icon: '⚙️',
fields: ['job_name', 'status', 'progress'],
displayField: 'job_name',
},
geo_intelligence: {
title: 'Geo Intelligence',
description: 'Location targeting data',
icon: '🗺️',
fields: ['city', 'state', 'zip', 'population'],
displayField: 'city',
},
headline_inventory: {
title: 'Headline Inventory',
description: 'Pre-written headlines library',
icon: '💬',
fields: ['headline_text', 'category'],
displayField: 'headline_text',
},
leads: {
title: 'Leads',
description: 'Customer lead management',
icon: '👤',
fields: ['name', 'email', 'status'],
displayField: 'name',
},
offer_blocks: {
title: 'Offer Blocks',
description: 'Call-to-action templates',
icon: '🎯',
fields: ['offer_text', 'offer_type'],
displayField: 'offer_text',
},
pages: {
title: 'Pages',
description: 'Static page content',
icon: '📄',
fields: ['title', 'slug', 'status'],
displayField: 'title',
},
posts: {
title: 'Posts',
description: 'Blog posts and articles',
icon: '📰',
fields: ['title', 'status', 'seo_score'],
displayField: 'title',
},
spintax_dictionaries: {
title: 'Spintax Dictionaries',
description: 'Word variation sets',
icon: '📚',
fields: ['category', 'variations'],
displayField: 'category',
},
};
export type CollectionName = keyof typeof collectionConfigs;

16
src/lib/db.ts Normal file
View File

@@ -0,0 +1,16 @@
import pg from 'pg';
const { Pool } = pg;
if (!process.env.DATABASE_URL) {
console.warn("⚠️ DATABASE_URL is missing. DB connections will fail.");
}
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
ssl: process.env.DATABASE_URL?.includes('sslmode=require') ? { rejectUnauthorized: false } : undefined
});
export const query = (text: string, params?: any[]) => pool.query(text, params);

55
src/lib/db/mechanic.ts Normal file
View File

@@ -0,0 +1,55 @@
import { query } from '../db';
export const MECHANIC_OPS = {
// 1. DIAGNOSTICS (The Stethoscope)
getHealth: async () => {
const connections = await query(`
SELECT count(*)::int as active, state
FROM pg_stat_activity
GROUP BY state;
`);
const size = await query(`
SELECT pg_size_pretty(pg_database_size(current_database())) as size;
`);
// Note: pg_statio_user_tables requires stats collection to be enabled (default on)
const cache = await query(`
SELECT
sum(heap_blks_read) as disk_read,
sum(heap_blks_hit) as mem_hit,
sum(heap_blks_hit) / NULLIF((sum(heap_blks_hit) + sum(heap_blks_read)), 0)::float as ratio
FROM pg_statio_user_tables;
`);
return {
connections: connections.rows,
size: size.rows[0]?.size || 'Unknown',
cache: cache.rows[0] || { ratio: 0 }
};
},
// 2. THE "RED BUTTON" COMMANDS (Fix It)
maintenance: {
vacuum: async () => {
// Cleans up dead rows and optimizes speed
await query('VACUUM (VERBOSE, ANALYZE);');
return "Vacuum Complete: DB optimized.";
},
reindex: async () => {
// Fixes corrupted or slow indexes
await query('REINDEX DATABASE directus;');
return "Reindex Complete: Indexes rebuilt.";
},
kill_locks: async () => {
// Kills any query running longer than 5 minutes
const res = await query(`
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'active'
AND (now() - query_start) > interval '5 minutes';
`);
return `Panic Protocol: Terminated ${res.rowCount} stuck processes.`;
}
}
};

View File

@@ -0,0 +1,12 @@
import { createDirectus, rest, authentication, realtime } from '@directus/sdk';
import type { DirectusSchema } from '@/lib/schemas';
const DIRECTUS_URL = import.meta.env.PUBLIC_DIRECTUS_URL || 'https://spark.jumpstartscaling.com';
export const directus = createDirectus<DirectusSchema>(DIRECTUS_URL)
.with(authentication('cookie', { autoRefresh: true }))
.with(rest())
.with(realtime());
// Re-export for convenience
export { readItems, readItem, createItem, updateItem, deleteItem, aggregate } from '@directus/sdk';

273
src/lib/directus/client.ts Normal file
View File

@@ -0,0 +1,273 @@
import { query } from '../db';
/**
* Directus Shim for Valhalla
* Translates Directus SDK calls to Raw SQL (Server) or Proxy API (Client).
*/
const isServer = typeof window === 'undefined';
const PROXY_ENDPOINT = '/api/god/proxy';
// --- Types ---
interface QueryCmp {
_eq?: any;
_neq?: any;
_gt?: any;
_lt?: any;
_contains?: any;
_in?: any[];
}
interface QueryFilter {
[field: string]: QueryCmp | QueryFilter | any;
_or?: QueryFilter[];
_and?: QueryFilter[];
}
interface Query {
filter?: QueryFilter;
fields?: string[];
limit?: number;
offset?: number;
sort?: string[];
aggregate?: any;
}
// --- SDK Mocks ---
export function readItems(collection: string, q?: Query) {
return { type: 'readItems', collection, query: q };
}
export function readItem(collection: string, id: string | number, q?: Query) {
return { type: 'readItem', collection, id, query: q };
}
export function createItem(collection: string, data: any) {
return { type: 'createItem', collection, data };
}
export function updateItem(collection: string, id: string | number, data: any) {
return { type: 'updateItem', collection, id, data };
}
export function deleteItem(collection: string, id: string | number) {
return { type: 'deleteItem', collection, id };
}
export function readSingleton(collection: string, q?: Query) {
return { type: 'readSingleton', collection, query: q };
}
export function aggregate(collection: string, q?: Query) {
return { type: 'aggregate', collection, query: q };
}
// --- Client Implementation ---
export function getDirectusClient() {
return {
request: async (command: any) => {
if (isServer) {
// SERVER-SIDE: Direct DB Access
return await executeCommand(command);
} else {
// CLIENT-SIDE: Proxy via HTTP
return await executeProxy(command);
}
}
};
}
// --- Proxy Execution (Client) ---
async function executeProxy(command: any) {
const token = localStorage.getItem('godToken') || ''; // Assuming auth token storage
const res = await fetch(PROXY_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(command)
});
if (!res.ok) {
let err = 'Unknown Error';
try { err = (await res.json()).error; } catch { }
throw new Error(err);
}
return await res.json();
}
// --- Server Execution (Server) ---
// This is exported so the Proxy Endpoint can use it too!
export async function executeCommand(command: any) {
try {
switch (command.type) {
case 'readItems':
return await executeReadItems(command.collection, command.query);
case 'readItem':
return await executeReadItem(command.collection, command.id, command.query);
case 'createItem':
return await executeCreateItem(command.collection, command.data);
case 'updateItem':
return await executeUpdateItem(command.collection, command.id, command.data);
case 'deleteItem':
return await executeDeleteItem(command.collection, command.id);
case 'aggregate':
return await executeAggregate(command.collection, command.query);
default:
throw new Error(`Unknown command type: ${command.type}`);
}
} catch (err: any) {
console.error(`Shim Error (${command.type} on ${command.collection}):`, err);
throw err;
}
}
// --- SQL Builders ---
async function executeReadItems(collection: string, q: Query = {}) {
// SECURITY: Validate collection name to prevent SQL injection via simple table name abuse
if (!/^[a-zA-Z0-9_]+$/.test(collection)) throw new Error("Invalid collection name");
let sql = `SELECT ${buildSelectFields(q.fields)} FROM "${collection}"`;
const params: any[] = [];
if (q.filter) {
const { where, vals } = buildWhere(q.filter, params);
if (where) sql += ` WHERE ${where}`;
}
// Sort
if (q.sort) {
const orderBy = q.sort.map(s => {
const desc = s.startsWith('-');
const field = desc ? s.substring(1) : s;
if (!/^[a-zA-Z0-9_]+$/.test(field)) return 'id'; // sanitize
return `"${field}" ${desc ? 'DESC' : 'ASC'}`;
}).join(', ');
if (orderBy) sql += ` ORDER BY ${orderBy}`;
}
// Limit/Offset
if (q.limit !== undefined && q.limit !== -1) sql += ` LIMIT ${q.limit}`;
if (q.offset) sql += ` OFFSET ${q.offset}`;
const res = await query(sql, params);
return res.rows;
}
async function executeReadItem(collection: string, id: string | number, q: Query = {}) {
if (!/^[a-zA-Z0-9_]+$/.test(collection)) throw new Error("Invalid collection name");
const res = await query(`SELECT * FROM "${collection}" WHERE id = $1`, [id]);
return res.rows[0];
}
async function executeCreateItem(collection: string, data: any) {
if (!/^[a-zA-Z0-9_]+$/.test(collection)) throw new Error("Invalid collection name");
const keys = Object.keys(data);
const vals = Object.values(data);
const placeholders = keys.map((_, i) => `$${i + 1}`).join(', ');
const cols = keys.map(k => `"${k}"`).join(', ');
const sql = `INSERT INTO "${collection}" (${cols}) VALUES (${placeholders}) RETURNING *`;
const res = await query(sql, vals);
return res.rows[0];
}
async function executeUpdateItem(collection: string, id: string | number, data: any) {
if (!/^[a-zA-Z0-9_]+$/.test(collection)) throw new Error("Invalid collection name");
const keys = Object.keys(data);
const vals = Object.values(data);
const setClause = keys.map((k, i) => `"${k}" = $${i + 2}`).join(', ');
const sql = `UPDATE "${collection}" SET ${setClause} WHERE id = $1 RETURNING *`;
const res = await query(sql, [id, ...vals]);
return res.rows[0];
}
async function executeDeleteItem(collection: string, id: string | number) {
if (!/^[a-zA-Z0-9_]+$/.test(collection)) throw new Error("Invalid collection name");
await query(`DELETE FROM "${collection}" WHERE id = $1`, [id]);
return true;
}
async function executeAggregate(collection: string, q: Query = {}) {
if (!/^[a-zA-Z0-9_]+$/.test(collection)) throw new Error("Invalid collection name");
if (q.aggregate?.count) {
let sql = `SELECT COUNT(*) as count FROM "${collection}"`;
const params: any[] = [];
if (q.filter) {
const { where, vals } = buildWhere(q.filter, params);
if (where) sql += ` WHERE ${where}`;
}
const res = await query(sql, params);
return [{ count: res.rows[0].count }];
}
return [];
}
// --- Query Helpers ---
function buildSelectFields(fields?: string[]) {
if (!fields || fields.includes('*') || fields.length === 0) return '*';
const cleanFields = fields.filter(f => typeof f === 'string');
if (cleanFields.length === 0) return '*';
return cleanFields.map(f => `"${f.replace(/[^a-zA-Z0-9_]/g, '')}"`).join(', ');
}
function buildWhere(filter: QueryFilter, params: any[]): { where: string, vals: any[] } {
const conditions: string[] = [];
if (filter._or) {
const orConds = filter._or.map(f => {
const res = buildWhere(f, params);
return `(${res.where})`;
});
conditions.push(`(${orConds.join(' OR ')})`);
return { where: conditions.join(' AND '), vals: params };
}
if (filter._and) {
const andConds = filter._and.map(f => {
const res = buildWhere(f, params);
return `(${res.where})`;
});
conditions.push(`(${andConds.join(' AND ')})`);
return { where: conditions.join(' AND '), vals: params };
}
for (const [key, val] of Object.entries(filter)) {
if (key.startsWith('_')) continue;
if (!/^[a-zA-Z0-9_]+$/.test(key)) continue; // skip invalid keys
if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
for (const [op, opVal] of Object.entries(val)) {
if (op === '_eq') {
params.push(opVal);
conditions.push(`"${key}" = $${params.length}`);
} else if (op === '_neq') {
params.push(opVal);
conditions.push(`"${key}" != $${params.length}`);
} else if (op === '_contains') {
params.push(`%${opVal}%`);
conditions.push(`"${key}" LIKE $${params.length}`);
} else if (op === '_gt') {
params.push(opVal);
conditions.push(`"${key}" > $${params.length}`);
} else if (op === '_lt') {
params.push(opVal);
conditions.push(`"${key}" < $${params.length}`);
}
}
} else {
params.push(val);
conditions.push(`"${key}" = $${params.length}`);
}
}
return { where: conditions.join(' AND '), vals: params };
}

View File

@@ -0,0 +1,319 @@
import { getDirectusClient } from './client';
import { readItems, readItem, readSingleton, aggregate } from '@directus/sdk';
import type { DirectusSchema, Pages as Page, Posts as Post, Sites as Site, DirectusUsers as User, Globals, Navigation } from '../schemas';
const directus = getDirectusClient();
/**
* Fetch a page by permalink (tenant-safe)
*/
export async function fetchPageByPermalink(
permalink: string,
siteId: string,
options?: { preview?: boolean; token?: string }
): Promise<Page | null> {
const filter: Record<string, any> = {
permalink: { _eq: permalink },
site_id: { _eq: siteId }
};
if (!options?.preview) {
filter.status = { _eq: 'published' };
}
try {
const pages = await directus.request(
readItems('pages', {
filter,
limit: 1,
fields: [
'id',
'title',
'permalink',
'site_id',
'status',
'seo_title',
'seo_description',
'seo_image',
'blocks', // Fetch as simple JSON field
'schema_json'
]
})
);
return pages?.[0] || null;
} catch (err) {
console.error('Error fetching page:', err);
return null;
}
}
/**
* Fetch site globals
*/
export async function fetchSiteGlobals(siteId: string): Promise<Globals | null> {
try {
const globals = await directus.request(
readItems('globals', {
filter: { site_id: { _eq: siteId } },
limit: 1,
fields: ['*']
})
);
// SDK returns array directly - cast only the final result
const result = globals as Globals[];
return result?.[0] ?? null;
} catch (err) {
console.error('Error fetching globals:', err);
return null;
}
}
/**
* Fetch site navigation
*/
export async function fetchNavigation(siteId: string): Promise<Partial<Navigation>[]> {
try {
const nav = await directus.request(
readItems('navigation', {
filter: { site_id: { _eq: siteId } },
sort: ['sort'],
fields: ['id', 'label', 'url', 'parent', 'target', 'sort']
})
);
// SDK returns array directly
return (nav as Navigation[]) ?? [];
} catch (err) {
console.error('Error fetching navigation:', err);
return [];
}
}
/**
* Fetch posts for a site
*/
export async function fetchPosts(
siteId: string,
options?: { limit?: number; page?: number; category?: string }
): Promise<{ posts: Partial<Post>[]; total: number }> {
const limit = options?.limit || 10;
const page = options?.page || 1;
const offset = (page - 1) * limit;
const filter: Record<string, any> = {
site_id: { _eq: siteId }, // siteId is UUID string
status: { _eq: 'published' }
};
if (options?.category) {
filter.category = { _eq: options.category };
}
try {
const [posts, countResult] = await Promise.all([
directus.request(
readItems('posts', {
filter,
limit,
offset,
sort: ['-published_at'],
fields: [
'id',
'title',
'slug',
'excerpt',
'featured_image',
'published_at',
'category',
'author',
'site_id',
'status',
'content'
]
})
),
directus.request(
aggregate('posts', {
aggregate: { count: '*' },
query: { filter }
})
)
]);
return {
posts: (posts as Partial<Post>[]) || [],
total: Number(countResult?.[0]?.count || 0)
};
} catch (err) {
console.error('Error fetching posts:', err);
return { posts: [], total: 0 };
}
}
/**
* Fetch a single post by slug
*/
export async function fetchPostBySlug(
slug: string,
siteId: string
): Promise<Post | null> {
try {
const posts = await directus.request(
readItems('posts', {
filter: {
slug: { _eq: slug },
site_id: { _eq: siteId },
status: { _eq: 'published' }
},
limit: 1,
fields: ['*']
})
);
return posts?.[0] || null;
} catch (err) {
console.error('Error fetching post:', err);
return null;
}
}
/**
* Fetch generated articles for a site
*/
export async function fetchGeneratedArticles(
siteId: string,
options?: { limit?: number; page?: number }
): Promise<{ articles: any[]; total: number }> {
const limit = options?.limit || 20;
const page = options?.page || 1;
const offset = (page - 1) * limit;
try {
const [articles, countResult] = await Promise.all([
directus.request(
readItems('generated_articles', {
filter: { site_id: { _eq: siteId } }, // UUID string
limit,
offset,
sort: ['-date_created'],
fields: ['*']
})
),
directus.request(
aggregate('generated_articles', {
aggregate: { count: '*' },
query: { filter: { site_id: { _eq: siteId } } } // UUID string
})
)
]);
return {
articles: articles || [],
total: Number(countResult?.[0]?.count || 0)
};
} catch (err) {
console.error('Error fetching articles:', err);
return { articles: [], total: 0 };
}
}
/**
* Fetch a single generated article by slug
*/
export async function fetchGeneratedArticleBySlug(
slug: string,
siteId: string
): Promise<any | null> {
try {
const articles = await directus.request(
readItems('generated_articles', {
filter: {
_and: [
{ slug: { _eq: slug } },
{ site_id: { _eq: siteId } },
{ is_published: { _eq: true } }
]
},
limit: 1,
fields: ['*']
})
);
return articles?.[0] || null;
} catch (err) {
console.error('Error fetching generated article:', err);
return null;
}
}
/**
* Fetch SEO campaigns
*/
export async function fetchCampaigns(siteId?: string) {
const filter: Record<string, any> = {};
if (siteId) {
filter._or = [
{ site_id: { _eq: siteId } },
{ site_id: { _null: true } }
];
}
try {
return await directus.request(
readItems('campaign_masters', {
filter,
sort: ['-date_created'],
fields: ['*']
})
);
} catch (err) {
console.error('Error fetching campaigns:', err);
return [];
}
}
/**
* Fetch locations (states, counties, cities)
*/
export async function fetchStates() {
try {
return await directus.request(
readItems('locations_states', {
sort: ['name'],
fields: ['*']
})
);
} catch (err) {
console.error('Error fetching states:', err);
return [];
}
}
export async function fetchCountiesByState(stateId: string) {
try {
return await directus.request(
readItems('locations_counties', {
filter: { state: { _eq: stateId } },
sort: ['name'],
fields: ['*']
})
);
} catch (err) {
console.error('Error fetching counties:', err);
return [];
}
}
export async function fetchCitiesByCounty(countyId: string, limit = 50) {
try {
return await directus.request(
readItems('locations_cities', {
filter: { county: { _eq: countyId } },
sort: ['-population'],
limit,
fields: ['*']
})
);
} catch (err) {
console.error('Error fetching cities:', err);
return [];
}
}

236
src/lib/godMode.ts Normal file
View File

@@ -0,0 +1,236 @@
/**
* God Mode Client Library
*
* Frontend client for god-mode API access
* Used by all admin pages for seamless operations
* Bypasses normal Directus auth via god token
*/
const GOD_MODE_BASE_URL = import.meta.env.PUBLIC_DIRECTUS_URL || 'https://spark.jumpstartscaling.com';
const GOD_TOKEN = import.meta.env.GOD_MODE_TOKEN || 'jmQXoeyxWoBsB7eHzG7FmnH90f22JtaYBxXHoorhfZ-v4tT3VNEr9vvmwHqYHCDoWXHSU4DeZXApCP-Gha-YdA';
class GodModeClient {
private token: string;
private baseUrl: string;
constructor(token: string = GOD_TOKEN) {
this.token = token;
this.baseUrl = `${GOD_MODE_BASE_URL}/god`;
}
async request(endpoint: string, options: RequestInit = {}): Promise<any> {
const url = `${this.baseUrl}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
'X-God-Token': this.token,
...options.headers
}
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'God mode request failed');
}
return response.json();
}
// === Status & Health ===
async getStatus() {
return this.request('/status');
}
// === Database Operations ===
async setupDatabase(sql: string): Promise<any> {
return this.request('/setup/database', {
method: 'POST',
body: JSON.stringify({ sql })
});
}
async executeSQL(sql: string, params: any[] = []): Promise<any> {
return this.request('/sql/execute', {
method: 'POST',
body: JSON.stringify({ sql, params })
});
}
// === Permissions ===
async grantAllPermissions(): Promise<any> {
return this.request('/permissions/grant-all', {
method: 'POST'
});
}
// === Collections ===
async getAllCollections(): Promise<any> {
return this.request('/collections/all');
}
// === Users ===
async makeUserAdmin(emailOrId: string): Promise<any> {
const body = typeof emailOrId === 'string' && emailOrId.includes('@')
? { email: emailOrId }
: { userId: emailOrId };
return this.request('/user/make-admin', {
method: 'POST',
body: JSON.stringify(body)
});
}
// === Schema Management ===
async createCollection(collection: string, fields: any[], meta: any = {}): Promise<any> {
return this.request('/schema/collections/create', {
method: 'POST',
body: JSON.stringify({ collection, fields, meta })
});
}
async addField(collection: string, field: string, type: string, meta: any = {}): Promise<any> {
return this.request('/schema/fields/create', {
method: 'POST',
body: JSON.stringify({ collection, field, type, meta })
});
}
async deleteField(collection: string, field: string): Promise<any> {
return this.request(`/schema/fields/${collection}/${field}`, {
method: 'DELETE'
});
}
async exportSchema(): Promise<any> {
return this.request('/schema/snapshot');
}
async applySchema(yaml: string): Promise<any> {
return this.request('/schema/apply', {
method: 'POST',
body: JSON.stringify({ yaml })
});
}
async createRelation(relation: any): Promise<any> {
return this.request('/schema/relations/create', {
method: 'POST',
body: JSON.stringify(relation)
});
}
// === Site Provisioning ===
async provisionSite({ name, domain, create_homepage = true, include_collections = [] }: {
name: string;
domain: string;
create_homepage?: boolean;
include_collections?: string[];
}): Promise<any> {
return this.request('/sites/provision', {
method: 'POST',
body: JSON.stringify({
name,
domain,
create_homepage,
include_collections
})
});
}
async addPageToSite(siteId: string, { title, slug, template = 'default' }: {
title: string;
slug: string;
template?: string;
}): Promise<any> {
return this.request(`/sites/${siteId}/add-page`, {
method: 'POST',
body: JSON.stringify({ title, slug, template })
});
}
// === Work Log ===
async logWork(data: { action: string; details: any; userId?: string }): Promise<any> {
return this.executeSQL(
'INSERT INTO work_log (action, details, user_id, timestamp) VALUES ($1, $2, $3, NOW()) RETURNING *',
[data.action, JSON.stringify(data.details), data.userId || 'god-mode']
);
}
async getWorkLog(limit: number = 100): Promise<any> {
return this.executeSQL(
`SELECT * FROM work_log ORDER BY timestamp DESC LIMIT ${limit}`
);
}
// === Error Logs ===
async logError(error: Error | any, context: any = {}): Promise<any> {
return this.executeSQL(
'INSERT INTO error_logs (error_message, stack_trace, context, timestamp) VALUES ($1, $2, $3, NOW()) RETURNING *',
[
error.message || error,
error.stack || '',
JSON.stringify(context)
]
);
}
async getErrorLogs(limit: number = 50): Promise<any> {
return this.executeSQL(
`SELECT * FROM error_logs ORDER BY timestamp DESC LIMIT ${limit}`
);
}
// === Job Queue ===
async addJob(jobType: string, payload: any, priority: number = 0): Promise<any> {
return this.executeSQL(
'INSERT INTO job_queue (job_type, payload, priority, status, created_at) VALUES ($1, $2, $3, $4, NOW()) RETURNING *',
[jobType, JSON.stringify(payload), priority, 'pending']
);
}
async getJobQueue(status: string | null = null): Promise<any> {
const sql = status
? `SELECT * FROM job_queue WHERE status = $1 ORDER BY priority DESC, created_at ASC`
: `SELECT * FROM job_queue ORDER BY priority DESC, created_at ASC`;
return this.executeSQL(sql, status ? [status] : []);
}
async updateJobStatus(jobId: string, status: string, result: any = null): Promise<any> {
return this.executeSQL(
'UPDATE job_queue SET status = $1, result = $2, updated_at = NOW() WHERE id = $3 RETURNING *',
[status, result ? JSON.stringify(result) : null, jobId]
);
}
async clearCompletedJobs(): Promise<any> {
return this.executeSQL(
"DELETE FROM job_queue WHERE status IN ('completed', 'failed') AND updated_at < NOW() - INTERVAL '7 days'"
);
}
// === Batch Operations ===
async batch(operations: Array<{ endpoint: string; method?: string; body?: any }>): Promise<any[]> {
const results: any[] = [];
for (const op of operations) {
try {
const result = await this.request(op.endpoint, {
method: op.method || 'GET',
body: op.body ? JSON.stringify(op.body) : undefined
});
results.push({ success: true, result });
} catch (error: unknown) {
const err = error as Error;
results.push({ success: false, error: err.message });
}
}
return results;
}
}
// Create singleton instance
export const godMode = new GodModeClient();
// Export class for custom instances
export default GodModeClient;

View File

@@ -0,0 +1,38 @@
export interface Pattern {
id: string;
name: string;
type: 'structure' | 'semantic' | 'conversion';
confidence: number;
occurrences: number;
last_detected: string;
tags: string[];
}
export interface GeoCluster {
id: string;
name: string;
location: string;
audience_size: number;
engagement_rate: number;
dominant_topic: string;
}
export interface AvatarMetric {
id: string;
avatar_id: string;
name: string;
articles_generated: number;
avg_engagement: number;
top_niche: string;
}
export interface IntelligenceState {
patterns: Pattern[];
geoClusters: GeoCluster[];
avatarMetrics: AvatarMetric[];
isLoading: boolean;
error: string | null;
fetchPatterns: () => Promise<void>;
fetchGeoClusters: () => Promise<void>;
fetchAvatarMetrics: () => Promise<void>;
}

View File

@@ -0,0 +1,51 @@
interface BatchConfig {
batchSize: number; // How many items to grab at once (e.g. 100)
concurrency: number; // How many to process in parallel (e.g. 5)
delayMs: number; // Throttle speed (e.g. wait 100ms between items)
}
export class BatchProcessor {
constructor(private config: BatchConfig) { }
async processQueue(
items: any[],
workerFunction: (item: any) => Promise<any>
) {
const results = [];
// Process in Chunks (Batch Size)
for (let i = 0; i < items.length; i += this.config.batchSize) {
const chunk = items.slice(i, i + this.config.batchSize);
console.log(`Processing Batch ${(i / this.config.batchSize) + 1}...`);
// Within each chunk, limit concurrency
const chunkResults = await this.runWithConcurrency(chunk, workerFunction);
results.push(...chunkResults);
// Optional: Cool down between batches
if (this.config.delayMs > 0) {
await new Promise(r => setTimeout(r, this.config.delayMs));
}
}
return results;
}
private async runWithConcurrency(items: any[], fn: (item: any) => Promise<any>) {
const results: any[] = [];
const executing: Promise<any>[] = [];
for (const item of items) {
const p = Promise.resolve().then(() => fn(item));
results.push(p);
if (this.config.concurrency <= items.length) {
const e: Promise<any> = p.then(() => executing.splice(executing.indexOf(e), 1));
executing.push(e);
if (executing.length >= this.config.concurrency) {
await Promise.race(executing);
}
}
}
return Promise.all(results);
}
}

44
src/lib/queue/config.ts Normal file
View File

@@ -0,0 +1,44 @@
/**
* BullMQ Configuration
* Job queue setup for content generation
*/
import { Queue, Worker, QueueOptions } from 'bullmq';
import IORedis from 'ioredis';
// Redis connection
const connection = new IORedis({
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
maxRetriesPerRequest: null,
});
// Queue options
const queueOptions: QueueOptions = {
connection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
removeOnComplete: {
count: 100,
age: 3600,
},
removeOnFail: {
count: 1000,
},
},
};
// Define queues
export const queues = {
generation: new Queue('generation', queueOptions),
publishing: new Queue('publishing', queueOptions),
svgImages: new Queue('svg-images', queueOptions),
wpSync: new Queue('wp-sync', queueOptions),
cleanup: new Queue('cleanup', queueOptions),
};
export { connection };

10
src/lib/react-query.ts Normal file
View File

@@ -0,0 +1,10 @@
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
},
},
});

404
src/lib/schemas.ts Normal file
View File

@@ -0,0 +1,404 @@
/**
* Spark Platform - Directus Schema Types
* Auto-generated from Golden Schema
*
* This provides full TypeScript coverage for all Directus collections
*/
// ============================================================================
// BATCH 1: FOUNDATION TABLES
// ============================================================================
export interface Sites {
id: string;
status: 'active' | 'inactive' | 'archived';
name: string;
url?: string;
date_created?: string;
date_updated?: string;
}
export interface CampaignMasters {
id: string;
status: 'active' | 'inactive' | 'completed';
site_id: string | Sites;
name: string;
headline_spintax_root?: string;
target_word_count?: number;
location_mode?: string;
batch_count?: number;
date_created?: string;
date_updated?: string;
}
export interface AvatarIntelligence {
id: string;
status: 'published' | 'draft';
base_name?: string; // Corrected from name
wealth_cluster?: string;
business_niches?: Record<string, any>;
pain_points?: Record<string, any>;
demographics?: Record<string, any>;
}
export interface AvatarVariants {
id: string;
status: 'published' | 'draft';
name?: string;
prompt_modifier?: string;
}
export interface CartesianPatterns {
id: string;
status: 'published' | 'draft';
name?: string;
pattern_logic?: string;
}
export interface GeoIntelligence {
id: string;
status: 'published' | 'draft';
city?: string;
state?: string;
population?: number;
}
export interface OfferBlocks {
id: string;
status: 'published' | 'draft';
name?: string;
html_content?: string;
}
// ============================================================================
// BATCH 2: FIRST-LEVEL CHILDREN
// ============================================================================
export interface GeneratedArticles {
id: string;
status: 'draft' | 'published' | 'archived';
site_id: string | Sites;
campaign_id?: string | CampaignMasters;
title?: string;
content?: string;
slug?: string;
is_published?: boolean;
schema_json?: Record<string, any>;
date_created?: string;
date_updated?: string;
}
export interface GenerationJobs {
id: string;
status: 'pending' | 'processing' | 'completed' | 'failed';
site_id: string | Sites;
batch_size?: number;
target_quantity?: number;
filters?: Record<string, any>;
current_offset?: number;
progress?: number;
}
export interface Pages {
id: string;
status: 'published' | 'draft';
site_id: string | Sites;
title?: string;
slug?: string;
permalink?: string;
content?: string;
blocks?: Record<string, any>;
schema_json?: Record<string, any>;
seo_title?: string;
seo_description?: string;
seo_image?: string | DirectusFiles;
date_created?: string;
date_updated?: string;
}
export interface Posts {
id: string;
status: 'published' | 'draft';
site_id: string | Sites;
title?: string;
slug?: string;
excerpt?: string;
content?: string;
featured_image?: string | DirectusFiles;
published_at?: string;
category?: string;
author?: string | DirectusUsers;
schema_json?: Record<string, any>;
date_created?: string;
date_updated?: string;
}
export interface Leads {
id: string;
status: 'new' | 'contacted' | 'qualified' | 'converted';
site_id?: string | Sites;
email?: string;
name?: string;
source?: string;
}
export interface HeadlineInventory {
id: string;
status: 'active' | 'used' | 'archived';
campaign_id: string | CampaignMasters;
headline_text?: string;
is_used?: boolean;
}
export interface ContentFragments {
id: string;
status: 'active' | 'archived';
campaign_id: string | CampaignMasters;
fragment_text?: string;
fragment_type?: string;
}
// ============================================================================
// BATCH 3: COMPLEX CHILDREN
// ============================================================================
export interface LinkTargets {
id: string;
status: 'active' | 'inactive';
site_id: string | Sites;
target_url?: string;
anchor_text?: string;
keyword_focus?: string;
}
export interface Globals {
id: string;
site_id: string | Sites;
title?: string;
description?: string;
logo?: string | DirectusFiles;
}
export interface Navigation {
id: string;
site_id: string | Sites;
label: string;
url: string;
parent?: string | Navigation;
target?: '_blank' | '_self';
sort?: number;
}
// ============================================================================
// DIRECTUS SYSTEM COLLECTIONS
// ============================================================================
export interface DirectusUsers {
id: string;
first_name?: string;
last_name?: string;
email: string;
password?: string;
location?: string;
title?: string;
description?: string;
tags?: string[];
avatar?: string;
language?: string;
theme?: 'auto' | 'light' | 'dark';
tfa_secret?: string;
status: 'active' | 'invited' | 'draft' | 'suspended' | 'archived';
role: string;
token?: string;
}
export interface DirectusFiles {
id: string;
storage: string;
filename_disk?: string;
filename_download: string;
title?: string;
type?: string;
folder?: string;
uploaded_by?: string | DirectusUsers;
uploaded_on?: string;
modified_by?: string | DirectusUsers;
modified_on?: string;
charset?: string;
filesize?: number;
width?: number;
height?: number;
duration?: number;
embed?: string;
description?: string;
location?: string;
tags?: string[];
metadata?: Record<string, any>;
}
export interface DirectusActivity {
id: number;
action: string;
user?: string | DirectusUsers;
timestamp: string;
ip?: string;
user_agent?: string;
collection: string;
item: string;
comment?: string;
}
// ============================================================================
// MAIN SCHEMA TYPE
// ============================================================================
export interface DirectusSchema {
// Batch 1: Foundation
sites: Sites[];
campaign_masters: CampaignMasters[];
avatar_intelligence: AvatarIntelligence[];
avatar_variants: AvatarVariants[];
cartesian_patterns: CartesianPatterns[];
geo_intelligence: GeoIntelligence[];
offer_blocks: OfferBlocks[];
// Batch 2: Children
generated_articles: GeneratedArticles[];
generation_jobs: GenerationJobs[];
pages: Pages[];
posts: Posts[];
leads: Leads[];
headline_inventory: HeadlineInventory[];
content_fragments: ContentFragments[];
// Batch 3: Complex
link_targets: LinkTargets[];
globals: Globals[];
navigation: Navigation[];
// System & Analytics
work_log: WorkLog[];
hub_pages: HubPages[];
forms: Forms[];
form_submissions: FormSubmissions[];
site_analytics: SiteAnalytics[];
events: AnalyticsEvents[];
pageviews: PageViews[];
conversions: Conversions[];
locations_states: LocationsStates[];
locations_counties: LocationsCounties[];
locations_cities: LocationsCities[];
// Directus System
directus_users: DirectusUsers[];
directus_files: DirectusFiles[];
directus_activity: DirectusActivity[];
}
// ============================================================================
// SYSTEM & ANALYTICS TYPES
// ============================================================================
export interface WorkLog {
id: number;
site_id?: string | Sites;
action: string;
entity_type?: string;
entity_id?: string;
details?: any;
level?: string;
status?: string;
timestamp?: string;
date_created?: string;
user?: string | DirectusUsers;
}
export interface HubPages {
id: string;
site_id: string | Sites;
title: string;
slug: string;
parent_hub?: string | HubPages;
level?: number;
articles_count?: number;
schema_json?: Record<string, any>;
}
export interface Forms {
id: string;
site_id: string | Sites;
name: string;
fields: any[];
submit_action?: string;
success_message?: string;
redirect_url?: string;
}
export interface FormSubmissions {
id: string;
form: string | Forms;
data: Record<string, any>;
date_created?: string;
}
export interface SiteAnalytics {
id: string;
site_id: string | Sites;
google_ads_id?: string;
fb_pixel_id?: string;
}
export interface AnalyticsEvents {
id: string;
site_id: string | Sites;
event_name: string;
page_path: string;
timestamp?: string;
}
export interface PageViews {
id: string;
site_id: string | Sites;
page_path: string;
session_id?: string;
timestamp?: string;
}
export interface Conversions {
id: string;
site_id: string | Sites;
lead?: string | Leads;
conversion_type: string;
value?: number;
}
export interface LocationsStates {
id: string;
name: string;
code: string;
}
export interface LocationsCities {
id: string;
name: string;
state: string | LocationsStates;
county?: string | LocationsCounties;
population?: number;
}
export interface LocationsCounties {
id: string;
name: string;
state: string | LocationsStates;
population?: number;
}
// ============================================================================
// HELPER TYPES
// ============================================================================
export type Collections = keyof DirectusSchema;
export type Item<Collection extends Collections> = DirectusSchema[Collection];
export type QueryFilter<Collection extends Collections> = Partial<Item<Collection>>;

361
src/lib/seo/cartesian.ts Normal file
View File

@@ -0,0 +1,361 @@
/**
* Spark Platform - Cartesian Permutation Engine
*
* Implements true Cartesian Product logic for spintax explosion:
* - n^k formula for total combinations
* - Location × Spintax cross-product
* - Iterator-based generation for memory efficiency
*
* The Cartesian Product generates ALL possible combinations where:
* - Every element of Set A combines with every element of Set B, C, etc.
* - Order matters: (A,B) ≠ (B,A)
* - Formula: n₁ × n₂ × n₃ × ... × nₖ
*
* @example
* Spintax: "{Best|Top} {Dentist|Clinic} in {city}"
* Cities: ["Austin", "Dallas"]
* Result: 2 × 2 × 2 = 8 unique headlines
*/
import type {
SpintaxSlot,
CartesianConfig,
CartesianResult,
CartesianMetadata,
LocationEntry,
VariableMap,
DEFAULT_CARTESIAN_CONFIG
} from '@/types/cartesian';
// Re-export the default config
export { DEFAULT_CARTESIAN_CONFIG } from '@/types/cartesian';
/**
* Extract all spintax slots from a template string
* Handles nested spintax by processing innermost first
*
* @param text - The template string with {option1|option2} syntax
* @returns Array of SpintaxSlot objects
*
* @example
* extractSpintaxSlots("{Best|Top} dentist")
* // Returns: [{ original: "{Best|Top}", options: ["Best", "Top"], position: 0, startIndex: 0, endIndex: 10 }]
*/
export function extractSpintaxSlots(text: string): SpintaxSlot[] {
const slots: SpintaxSlot[] = [];
// Match innermost braces only (no nested braces inside)
const pattern = /\{([^{}]+)\}/g;
let match: RegExpExecArray | null;
let position = 0;
while ((match = pattern.exec(text)) !== null) {
// Only treat as spintax if it contains pipe separator
if (match[1].includes('|')) {
slots.push({
original: match[0],
options: match[1].split('|').map(s => s.trim()),
position: position++,
startIndex: match.index,
endIndex: match.index + match[0].length
});
}
}
return slots;
}
/**
* Calculate total combinations using the n^k (Cartesian product) formula
*
* For k slots with n₁, n₂, ..., nₖ options respectively:
* Total = n₁ × n₂ × n₃ × ... × nₖ
*
* @param slots - Array of spintax slots
* @param locationCount - Number of locations to cross with (default 1)
* @returns Total number of possible combinations, capped at safe integer max
*/
export function calculateTotalCombinations(
slots: SpintaxSlot[],
locationCount: number = 1
): number {
if (slots.length === 0 && locationCount <= 1) {
return 1;
}
let total = Math.max(locationCount, 1);
for (const slot of slots) {
total *= slot.options.length;
// Safety check to prevent overflow
if (total > Number.MAX_SAFE_INTEGER) {
return Number.MAX_SAFE_INTEGER;
}
}
return total;
}
/**
* Generate all Cartesian product combinations from spintax slots
* Uses an iterative approach with index-based selection for memory efficiency
*
* The algorithm works like a "combination lock" or odometer:
* - Each slot is a dial with n options
* - We count through all n₁ × n₂ × ... × nₖ combinations
* - The index maps to specific choices via modular arithmetic
*
* @param template - Original template string
* @param slots - Extracted spintax slots
* @param config - Generation configuration
* @yields CartesianResult for each combination
*/
export function* generateCartesianProduct(
template: string,
slots: SpintaxSlot[],
config: Partial<CartesianConfig> = {}
): Generator<CartesianResult> {
const { maxCombinations = 10000, offset = 0 } = config;
if (slots.length === 0) {
yield {
text: template,
slotValues: {},
index: 0
};
return;
}
const totalCombinations = calculateTotalCombinations(slots);
const limit = Math.min(totalCombinations, maxCombinations);
const startIndex = Math.min(offset, totalCombinations);
// Pre-calculate divisors for index-to-options mapping
const divisors: number[] = [];
let divisor = 1;
for (let i = slots.length - 1; i >= 0; i--) {
divisors[i] = divisor;
divisor *= slots[i].options.length;
}
// Generate combinations using index-based selection
for (let index = startIndex; index < Math.min(startIndex + limit, totalCombinations); index++) {
let result = template;
const slotValues: Record<string, string> = {};
// Map index to specific option choices (like reading an odometer)
for (let i = 0; i < slots.length; i++) {
const slot = slots[i];
const optionIndex = Math.floor(index / divisors[i]) % slot.options.length;
const chosenOption = slot.options[optionIndex];
slotValues[`slot_${i}`] = chosenOption;
result = result.replace(slot.original, chosenOption);
}
yield {
text: result,
slotValues,
index
};
}
}
/**
* Generate full Cartesian product including location cross-product
*
* This creates the FULL cross-product:
* (Spintax combinations) × (Location variations)
*
* @param template - The spintax template
* @param locations - Array of location entries to cross with
* @param nicheVariables - Additional variables to inject
* @param config - Generation configuration
* @yields CartesianResult with location data
*/
export function* generateWithLocations(
template: string,
locations: LocationEntry[],
nicheVariables: VariableMap = {},
config: Partial<CartesianConfig> = {}
): Generator<CartesianResult> {
const { maxCombinations = 10000 } = config;
const slots = extractSpintaxSlots(template);
const spintaxCombinations = calculateTotalCombinations(slots);
const locationCount = Math.max(locations.length, 1);
const totalCombinations = spintaxCombinations * locationCount;
let generated = 0;
// If no locations, just generate spintax variations
if (locations.length === 0) {
for (const result of generateCartesianProduct(template, slots, config)) {
if (generated >= maxCombinations) return;
// Inject niche variables
const text = injectVariables(result.text, nicheVariables);
yield {
...result,
text,
index: generated++
};
}
return;
}
// Full cross-product: spintax × locations
for (const location of locations) {
// Build location variables
const locationVars: VariableMap = {
city: location.city || '',
county: location.county || '',
state: location.state,
state_code: location.stateCode,
population: String(location.population || '')
};
// Merge with niche variables
const allVariables = { ...nicheVariables, ...locationVars };
// Generate all spintax combinations for this location
for (const result of generateCartesianProduct(template, slots, { maxCombinations: Infinity })) {
if (generated >= maxCombinations) return;
// Inject all variables
const text = injectVariables(result.text, allVariables);
yield {
text,
slotValues: result.slotValues,
location: {
city: location.city,
county: location.county,
state: location.state,
stateCode: location.stateCode,
id: location.id
},
index: generated++
};
}
}
}
/**
* Inject variables into text, replacing {varName} placeholders
* Unlike spintax, variable placeholders don't contain pipe separators
*
* @param text - Text with {variable} placeholders
* @param variables - Map of variable names to values
* @returns Text with variables replaced
*/
export function injectVariables(text: string, variables: VariableMap): string {
let result = text;
for (const [key, value] of Object.entries(variables)) {
// Match {key} but NOT {key|other} (that's spintax)
const pattern = new RegExp(`\\{${key}\\}`, 'gi');
result = result.replace(pattern, value);
}
return result;
}
/**
* Parse spintax and randomly select ONE variation (for content fragments)
* This is different from Cartesian explosion - it picks a single random path
*
* @param text - Text with spintax {option1|option2}
* @returns Single randomly selected variation
*/
export function parseSpintaxRandom(text: string): string {
const pattern = /\{([^{}]+)\}/g;
function processMatch(_match: string, group: string): string {
if (!group.includes('|')) {
return `{${group}}`; // Not spintax, preserve as variable placeholder
}
const options = group.split('|');
return options[Math.floor(Math.random() * options.length)];
}
let result = text;
let previousResult = '';
// Process nested spintax (innermost first)
while (result !== previousResult) {
previousResult = result;
result = result.replace(pattern, processMatch);
}
return result;
}
/**
* Explode spintax into ALL variations without locations
* Convenience function for simple use cases
*
* @param text - Spintax template
* @param maxCount - Maximum results
* @returns Array of all variations
*/
export function explodeSpintax(text: string, maxCount = 5000): string[] {
const slots = extractSpintaxSlots(text);
const results: string[] = [];
for (const result of generateCartesianProduct(text, slots, { maxCombinations: maxCount })) {
results.push(result.text);
}
return results;
}
/**
* Get metadata about a Cartesian product without running generation
* Useful for UI to show "This will generate X combinations"
*
* @param template - Spintax template
* @param locationCount - Number of locations
* @param maxCombinations - Generation limit
* @returns Metadata object
*/
export function getCartesianMetadata(
template: string,
locationCount: number = 1,
maxCombinations: number = 10000
): CartesianMetadata {
const slots = extractSpintaxSlots(template);
const totalSpintaxCombinations = calculateTotalCombinations(slots);
const totalPossibleCombinations = totalSpintaxCombinations * Math.max(locationCount, 1);
const generatedCount = Math.min(totalPossibleCombinations, maxCombinations);
return {
template,
slotCount: slots.length,
totalSpintaxCombinations,
locationCount,
totalPossibleCombinations,
generatedCount,
wasTruncated: totalPossibleCombinations > maxCombinations
};
}
/**
* Collect results from a generator into an array
* Helper for when you need all results at once
*/
export function collectResults(
generator: Generator<CartesianResult>,
limit?: number
): CartesianResult[] {
const results: CartesianResult[] = [];
let count = 0;
for (const result of generator) {
results.push(result);
count++;
if (limit && count >= limit) break;
}
return results;
}

View File

@@ -0,0 +1,176 @@
/**
* SVG Featured Image Generator
*
* Generates SEO-optimized featured images from templates.
* - Replaces {title}, {subtitle}, colors, fonts
* - Returns SVG string and base64 data URI
* - Generates SEO-friendly filenames from titles
*/
export interface ImageGeneratorInput {
title: string;
subtitle?: string;
template?: ImageTemplate;
}
export interface ImageTemplate {
svg_source: string;
width?: number;
height?: number;
background_gradient_start?: string;
background_gradient_end?: string;
text_color?: string;
font_family?: string;
title_font_size?: number;
subtitle_text?: string;
subtitle_font_size?: number;
}
export interface GeneratedImage {
svg: string;
dataUri: string;
filename: string;
alt: string;
width: number;
height: number;
}
// Default professional template
const DEFAULT_TEMPLATE: ImageTemplate = {
svg_source: `<svg width="{width}" height="{height}" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:{gradient_start};stop-opacity:1" />
<stop offset="100%" style="stop-color:{gradient_end};stop-opacity:1" />
</linearGradient>
</defs>
<rect width="{width}" height="{height}" fill="url(#grad)"/>
<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="{font_family}" font-weight="bold" font-size="{title_size}" fill="{text_color}">
{title}
</text>
<text x="50%" y="85%" text-anchor="middle" font-family="{font_family}" font-size="{subtitle_size}" fill="rgba(255,255,255,0.7)">
{subtitle}
</text>
</svg>`,
width: 1200,
height: 630,
background_gradient_start: '#2563eb',
background_gradient_end: '#1d4ed8',
text_color: '#ffffff',
font_family: 'Arial, sans-serif',
title_font_size: 48,
subtitle_text: '',
subtitle_font_size: 18
};
/**
* Generate SEO-friendly filename from title
* "Best Dentist in Austin, TX" -> "best-dentist-in-austin-tx.svg"
*/
export function generateFilename(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '') // Remove special chars
.replace(/\s+/g, '-') // Spaces to dashes
.replace(/-+/g, '-') // Multiple dashes to single
.substring(0, 60) // Limit length
+ '.svg';
}
/**
* Wrap long titles to multiple lines if needed
*/
function wrapTitle(title: string, maxCharsPerLine: number = 40): string[] {
const words = title.split(' ');
const lines: string[] = [];
let currentLine = '';
for (const word of words) {
if ((currentLine + ' ' + word).trim().length <= maxCharsPerLine) {
currentLine = (currentLine + ' ' + word).trim();
} else {
if (currentLine) lines.push(currentLine);
currentLine = word;
}
}
if (currentLine) lines.push(currentLine);
return lines.slice(0, 3); // Max 3 lines
}
/**
* Generate a featured image from a template
*/
export function generateFeaturedImage(input: ImageGeneratorInput): GeneratedImage {
const template = input.template || DEFAULT_TEMPLATE;
const width = template.width || 1200;
const height = template.height || 630;
// Process title for multi-line if needed
const titleLines = wrapTitle(input.title);
const isSingleLine = titleLines.length === 1;
// Build title text elements
let titleSvg: string;
if (isSingleLine) {
titleSvg = `<text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" font-family="${template.font_family}" font-weight="bold" font-size="${template.title_font_size}" fill="${template.text_color}">${escapeXml(input.title)}</text>`;
} else {
const lineHeight = (template.title_font_size || 48) * 1.2;
const startY = (height / 2) - ((titleLines.length - 1) * lineHeight / 2);
titleSvg = titleLines.map((line, i) =>
`<text x="50%" y="${startY + (i * lineHeight)}" dominant-baseline="middle" text-anchor="middle" font-family="${template.font_family}" font-weight="bold" font-size="${template.title_font_size}" fill="${template.text_color}">${escapeXml(line)}</text>`
).join('\n');
}
// Replace template variables
let svg = template.svg_source
.replace(/{width}/g, String(width))
.replace(/{height}/g, String(height))
.replace(/{width-80}/g, String(width - 80))
.replace(/{height-80}/g, String(height - 80))
.replace(/{gradient_start}/g, template.background_gradient_start || '#2563eb')
.replace(/{gradient_end}/g, template.background_gradient_end || '#1d4ed8')
.replace(/{text_color}/g, template.text_color || '#ffffff')
.replace(/{accent_color}/g, template.background_gradient_start || '#2563eb')
.replace(/{font_family}/g, template.font_family || 'Arial, sans-serif')
.replace(/{title_size}/g, String(template.title_font_size || 48))
.replace(/{subtitle_size}/g, String(template.subtitle_font_size || 18))
.replace(/{title}/g, escapeXml(input.title))
.replace(/{subtitle}/g, escapeXml(input.subtitle || template.subtitle_text || ''));
// Generate base64 data URI for inline use
// Use TextEncoder for Node 18+ and browser compatibility
const encoder = new TextEncoder();
const bytes = encoder.encode(svg);
const base64 = btoa(String.fromCharCode(...bytes));
const dataUri = `data:image/svg+xml;base64,${base64}`;
return {
svg,
dataUri,
filename: generateFilename(input.title),
alt: `${input.title} - Featured Image`,
width,
height
};
}
/**
* Generate HTML img tag for the featured image
*/
export function generateImageTag(image: GeneratedImage, useSrcPath?: string): string {
const src = useSrcPath || image.dataUri;
return `<img src="${src}" alt="${escapeXml(image.alt)}" width="${image.width}" height="${image.height}" loading="lazy" />`;
}
/**
* Escape XML special characters
*/
function escapeXml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}

View File

@@ -0,0 +1,195 @@
/**
* Gaussian Velocity Scheduler
*
* Distributes articles over a date range using natural velocity patterns
* to simulate organic content growth and avoid spam footprints.
*/
export type VelocityMode = 'RAMP_UP' | 'RANDOM_SPIKES' | 'STEADY';
export interface VelocityConfig {
mode: VelocityMode;
weekendThrottle: boolean;
jitterMinutes: number;
businessHoursOnly: boolean;
}
export interface ScheduleEntry {
publishDate: Date;
modifiedDate: Date;
}
/**
* Generate a natural schedule for article publication
*
* @param startDate - Earliest backdate
* @param endDate - Latest date (usually today)
* @param totalArticles - Number of articles to schedule
* @param config - Velocity configuration
* @returns Array of scheduled dates
*/
export function generateNaturalSchedule(
startDate: Date,
endDate: Date,
totalArticles: number,
config: VelocityConfig
): ScheduleEntry[] {
const now = new Date();
const totalDays = Math.floor((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
if (totalDays <= 0 || totalArticles <= 0) {
return [];
}
// Build probability weights for each day
const dayWeights: { date: Date; weight: number }[] = [];
for (let dayOffset = 0; dayOffset < totalDays; dayOffset++) {
const currentDate = new Date(startDate);
currentDate.setDate(currentDate.getDate() + dayOffset);
const dayOfWeek = currentDate.getDay();
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
let weight = 1.0;
// Apply velocity mode
switch (config.mode) {
case 'RAMP_UP':
// Weight grows from 0.2 (20% volume) to 1.0 (100% volume)
const progress = dayOffset / totalDays;
weight = 0.2 + (0.8 * progress);
break;
case 'RANDOM_SPIKES':
// 5% chance of a content sprint (3x volume)
if (Math.random() < 0.05) {
weight = 3.0;
}
break;
case 'STEADY':
default:
weight = 1.0;
break;
}
// Add human noise (±15% randomness)
weight *= 0.85 + (Math.random() * 0.30);
// Weekend throttle (reduce by 80%)
if (config.weekendThrottle && isWeekend) {
weight *= 0.2;
}
dayWeights.push({ date: currentDate, weight });
}
// Normalize and distribute articles
const totalWeight = dayWeights.reduce((sum, d) => sum + d.weight, 0);
const scheduleQueue: ScheduleEntry[] = [];
for (const dayEntry of dayWeights) {
// Calculate how many articles for this day
const rawCount = (dayEntry.weight / totalWeight) * totalArticles;
// Probabilistic rounding
let count = Math.floor(rawCount);
if (Math.random() < (rawCount - count)) {
count += 1;
}
// Generate timestamps with jitter
for (let i = 0; i < count; i++) {
let hour: number;
if (config.businessHoursOnly) {
// Gaussian centered at 2 PM, clamped to 9-18
hour = Math.round(gaussianRandom(14, 2));
hour = Math.max(9, Math.min(18, hour));
} else {
// Any hour with slight bias toward afternoon
hour = Math.round(gaussianRandom(14, 4));
hour = Math.max(0, Math.min(23, hour));
}
const minute = Math.floor(Math.random() * 60);
// Apply jitter to the base hour
const jitterOffset = Math.floor((Math.random() - 0.5) * 2 * config.jitterMinutes);
const publishDate = new Date(dayEntry.date);
publishDate.setHours(hour, minute, 0, 0);
publishDate.setMinutes(publishDate.getMinutes() + jitterOffset);
// SEO TRICK: If older than 6 months, set modified date to today
const sixMonthsAgo = new Date(now);
sixMonthsAgo.setMonth(sixMonthsAgo.getMonth() - 6);
const modifiedDate = publishDate < sixMonthsAgo
? randomDateWithin7Days(now) // Set to recent date for freshness signal
: new Date(publishDate);
scheduleQueue.push({ publishDate, modifiedDate });
}
}
// Sort chronologically
scheduleQueue.sort((a, b) => a.publishDate.getTime() - b.publishDate.getTime());
return scheduleQueue;
}
/**
* Generate a Gaussian random number
* Uses Box-Muller transform
*/
function gaussianRandom(mean: number, stdDev: number): number {
let u = 0, v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
const z = Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
return z * stdDev + mean;
}
/**
* Generate a random date within 7 days of target
*/
function randomDateWithin7Days(target: Date): Date {
const offset = Math.floor(Math.random() * 7);
const result = new Date(target);
result.setDate(result.getDate() - offset);
result.setHours(
Math.floor(Math.random() * 10) + 9, // 9 AM - 7 PM
Math.floor(Math.random() * 60),
0, 0
);
return result;
}
/**
* Calculate max backdate based on domain age
*
* @param domainAgeYears - How old the domain is
* @returns Earliest date that's safe to backdate to
*/
export function getMaxBackdateStart(domainAgeYears: number): Date {
const now = new Date();
// Can only backdate to when domain existed, minus a small buffer
const maxYears = Math.max(0, domainAgeYears - 0.25); // 3 month buffer
const result = new Date(now);
result.setFullYear(result.getFullYear() - maxYears);
return result;
}
/**
* Create a context-aware year token replacer
* Replaces {Current_Year} and {Next_Year} based on publish date
*/
export function replaceYearTokens(content: string, publishDate: Date): string {
const year = publishDate.getFullYear();
return content
.replace(/\{Current_Year\}/g, year.toString())
.replace(/\{Next_Year\}/g, (year + 1).toString())
.replace(/\{Last_Year\}/g, (year - 1).toString());
}

95
src/lib/testing/seo.ts Normal file
View File

@@ -0,0 +1,95 @@
/**
* SEO Analysis Engine
* Checks content against common SEO best practices.
*/
interface SeoResult {
score: number;
issues: string[];
}
export function analyzeSeo(content: string, keyword: string): SeoResult {
const issues: string[] = [];
let score = 100;
if (!content) return { score: 0, issues: ['No content provided'] };
const lowerContent = content.toLowerCase();
const lowerKeyword = keyword.toLowerCase();
// 1. Keyword Presence
if (keyword && !lowerContent.includes(lowerKeyword)) {
score -= 20;
issues.push(`Primary keyword "${keyword}" is missing from content.`);
}
// 2. Keyword Density (Simple)
if (keyword) {
const matches = lowerContent.match(new RegExp(lowerKeyword, 'g'));
const count = matches ? matches.length : 0;
const words = content.split(/\s+/).length;
const density = (count / words) * 100;
if (density > 3) {
score -= 10;
issues.push(`Keyword density is too high (${density.toFixed(1)}%). Aim for < 3%.`);
}
}
// 3. Word Count
const wordCount = content.split(/\s+/).length;
if (wordCount < 300) {
score -= 15;
issues.push(`Content is too short (${wordCount} words). Recommended minimum is 300.`);
}
// 4. Heading Structure (Basic Check for H1/H2)
// Note: If content is just body text, this might not apply suitable unless full HTML
if (content.includes('<h1>') && (content.match(/<h1>/g) || []).length > 1) {
score -= 10;
issues.push('Multiple H1 tags detected. Use only one H1 per page.');
}
return { score: Math.max(0, score), issues };
}
/**
* Readability Analysis Engine
* Uses Flesch-Kincaid Grade Level
*/
export function analyzeReadability(content: string): { gradeLevel: number; score: number; feedback: string } {
// Basic heuristics
const sentences = content.split(/[.!?]+/).length;
const words = content.split(/\s+/).length;
const syllables = countSyllables(content);
// Flesch-Kincaid Grade Level Formula
// 0.39 * (words/sentences) + 11.8 * (syllables/words) - 15.59
const avgWordsPerSentence = words / Math.max(1, sentences);
const avgSyllablesPerWord = syllables / Math.max(1, words);
const gradeLevel = (0.39 * avgWordsPerSentence) + (11.8 * avgSyllablesPerWord) - 15.59;
let feedback = "Easy to read";
if (gradeLevel > 12) feedback = "Difficult (University level)";
else if (gradeLevel > 8) feedback = "Average (High School level)";
// Normalized 0-100 score (lower grade level = higher score usually for SEO)
const score = Math.max(0, Math.min(100, 100 - (gradeLevel * 5)));
return {
gradeLevel: parseFloat(gradeLevel.toFixed(1)),
score: Math.round(score),
feedback
};
}
// Simple syllable counter approximation
function countSyllables(text: string): number {
return text.toLowerCase()
.replace(/[^a-z]/g, '')
.replace(/e$/g, '') // silent e
.replace(/[aeiouy]{1,2}/g, 'x') // vowel groups
.split('x').length - 1 || 1;
}

138
src/lib/theme/config.ts Normal file
View File

@@ -0,0 +1,138 @@
/**
* Spark Pro Design System
* Theme Configuration & Guidelines
*/
export const sparkTheme = {
// === THE SYSTEM ===
name: 'Titanium Pro',
description: 'Luxury Industrial - Matte Black with Gold Accents',
// === COLOR RULES ===
rules: {
surfaces: {
void: 'bg-void', // Pure black background
titanium: 'bg-titanium', // Main panels (with border)
graphite: 'bg-graphite', // Inputs/secondary cards
jet: 'bg-jet', // Popups/modals
},
borders: {
standard: 'border border-edge-normal', // All containers
subtle: 'border border-edge-subtle', // Dividers
active: 'border border-edge-bright', // Hover/focus
selected: 'border border-edge-gold', // Selected state
},
text: {
primary: 'text-white', // Headlines, important data
secondary: 'text-silver', // Body text (darkest allowed)
data: 'text-gold-300', // Numbers, metrics
dimmed: 'text-white/60', // Less important
monospace: 'font-mono text-gold-300', // All data/numbers
},
shadows: {
card: 'shadow-hard', // Block shadow for depth
glow: 'shadow-glow-gold', // Glowing accent
none: '', // Flat elements
},
},
// === COMPONENT PATTERNS ===
components: {
card: 'bg-titanium border border-edge-normal shadow-hard rounded-lg',
cardHover: 'hover:border-edge-gold transition-colors',
button: {
primary: 'bg-gold-gradient text-black font-semibold border-t border-white/40 shadow-glow-gold',
secondary: 'bg-titanium border border-edge-normal hover:border-edge-bright',
ghost: 'hover:bg-graphite',
},
input: 'bg-graphite border border-edge-subtle text-white placeholder:text-silver/50',
table: {
header: 'border-b border-edge-normal bg-titanium',
row: 'border-b border-edge-subtle hover:bg-graphite/50',
cell: 'border-r border-edge-subtle/50',
},
status: {
active: 'bg-void border border-edge-gold text-gold-300',
processing: 'bg-void border border-electric-400 text-electric-400 animate-pulse',
complete: 'bg-void border border-green-500 text-green-400',
error: 'bg-void border border-red-500 text-red-400',
},
},
// === TYPOGRAPHY SYSTEM ===
typography: {
heading: 'font-sans tracking-tight text-white',
body: 'font-sans text-silver',
data: 'font-mono tracking-wider text-gold-300',
label: 'text-silver uppercase text-[10px] tracking-[0.2em]',
},
// === THE "NO-BLEND" CHECKLIST ===
checklist: [
'✅ Every container has a 1px border',
'✅ Never put dark on dark without border',
'✅ Use staircase: void → titanium → graphite → jet',
'✅ All data is monospace gold',
'✅ Text minimum is silver (#D1D5DB)',
'✅ Active states use gold borders',
'✅ Shadows are hard, not fuzzy',
],
};
// === ALTERNATIVE THEMES (Future) ===
export const alternativeThemes = {
'deep-ocean': {
name: 'Deep Ocean',
void: '#001219',
titanium: '#0A1929',
gold: '#00B4D8',
description: 'Navy blue with cyan accents',
},
'forest-command': {
name: 'Forest Command',
void: '#0D1B0C',
titanium: '#1A2E1A',
gold: '#4ADE80',
description: 'Dark green with emerald accents',
},
'crimson-steel': {
name: 'Crimson Steel',
void: '#0F0000',
titanium: '#1F0A0A',
gold: '#DC2626',
description: 'Dark red with crimson accents',
},
};
// === USAGE EXAMPLES ===
export const examples = {
dashboard: {
container: 'min-h-screen bg-void p-6',
panel: 'bg-titanium border border-edge-normal rounded-lg p-6 shadow-hard',
statCard: 'bg-titanium border border-edge-normal rounded-lg p-6 hover:border-edge-gold transition-colors',
number: 'text-4xl font-mono text-gold-300 tracking-wider',
},
factory: {
kanbanLane: 'bg-void/50 border-r border-edge-subtle',
card: 'bg-titanium border border-edge-normal rounded-lg p-4 shadow-hard hover:border-edge-gold cursor-pointer',
cardActive: 'border-edge-gold shadow-hard-gold',
},
form: {
label: 'text-silver uppercase text-[10px] tracking-[0.2em] mb-2',
input: 'bg-graphite border border-edge-subtle text-white px-4 py-2 rounded focus:border-edge-gold',
button: 'bg-gold-gradient text-black font-semibold px-6 py-3 rounded border-t border-white/40 shadow-glow-gold',
},
};
export default sparkTheme;

7
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,7 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,103 @@
/**
* Circuit Breaker
* Prevents cascading failures for external services
*/
export interface CircuitBreakerOptions {
failureThreshold: number;
resetTimeout: number;
monitoringPeriod: number;
}
export class CircuitBreaker {
private failures = 0;
private lastFailureTime: number | null = null;
private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
constructor(
private name: string,
private options: CircuitBreakerOptions = {
failureThreshold: 5,
resetTimeout: 60000, // 1 minute
monitoringPeriod: 10000, // 10 seconds
}
) { }
async execute<T>(operation: () => Promise<T>, fallback?: () => Promise<T>): Promise<T> {
// Check if circuit is open
if (this.state === 'OPEN') {
const timeSinceLastFailure = Date.now() - (this.lastFailureTime || 0);
if (timeSinceLastFailure > this.options.resetTimeout) {
this.state = 'HALF_OPEN';
this.failures = 0;
} else {
console.warn(`[CircuitBreaker:${this.name}] Circuit is OPEN, using fallback`);
if (fallback) {
return fallback();
}
throw new Error(`Circuit breaker open for ${this.name}`);
}
}
try {
const result = await operation();
// Success - reset if in half-open state
if (this.state === 'HALF_OPEN') {
this.state = 'CLOSED';
this.failures = 0;
console.log(`[CircuitBreaker:${this.name}] Circuit closed after recovery`);
}
return result;
} catch (error) {
this.failures++;
this.lastFailureTime = Date.now();
console.error(`[CircuitBreaker:${this.name}] Failure ${this.failures}/${this.options.failureThreshold}`);
// Open circuit if threshold reached
if (this.failures >= this.options.failureThreshold) {
this.state = 'OPEN';
console.error(`[CircuitBreaker:${this.name}] Circuit OPENED due to failures`);
}
// Use fallback if available
if (fallback) {
return fallback();
}
throw error;
}
}
getStatus() {
return {
state: this.state,
failures: this.failures,
lastFailureTime: this.lastFailureTime,
};
}
reset() {
this.state = 'CLOSED';
this.failures = 0;
this.lastFailureTime = null;
}
}
// Pre-configured circuit breakers
export const breakers = {
wordpress: new CircuitBreaker('WordPress', {
failureThreshold: 3,
resetTimeout: 30000,
monitoringPeriod: 5000,
}),
directus: new CircuitBreaker('Directus', {
failureThreshold: 5,
resetTimeout: 60000,
monitoringPeriod: 10000,
}),
};

64
src/lib/utils/dry-run.ts Normal file
View File

@@ -0,0 +1,64 @@
/**
* Dry Run Mode
* Preview generation without saving to database
*/
import type { Article } from '@/lib/validation/schemas';
export interface DryRunResult {
preview: Article;
blocks_used: string[];
variables_injected: Record<string, string>;
spintax_resolved: boolean;
estimated_seo_score: number;
warnings: string[];
processing_time_ms: number;
}
export async function dryRunGeneration(
patternId: string,
avatarId: string,
geoCity: string,
geoState: string,
keyword: string
): Promise<DryRunResult> {
const startTime = Date.now();
const warnings: string[] = [];
// Simulate generation process without saving
const preview: Article = {
id: 'dry-run-preview',
collection_id: 'dry-run',
status: 'review',
title: `Preview: ${keyword} in ${geoCity}, ${geoState}`,
slug: 'dry-run-preview',
content_html: '<p>This is a dry-run preview. No data was saved.</p>',
geo_city: geoCity,
geo_state: geoState,
seo_score: 75,
is_published: false,
};
// Track what would be used
const blocks_used = [
'intro-block-123',
'problem-block-456',
'solution-block-789',
];
const variables_injected = {
city: geoCity,
state: geoState,
keyword,
};
return {
preview,
blocks_used,
variables_injected,
spintax_resolved: true,
estimated_seo_score: 75,
warnings,
processing_time_ms: Date.now() - startTime,
};
}

56
src/lib/utils/logger.ts Normal file
View File

@@ -0,0 +1,56 @@
/**
* Work Log Helper
* Centralized logging to work_log collection
*/
import { getDirectusClient } from '@/lib/directus/client';
import { createItem } from '@directus/sdk';
export type LogLevel = 'info' | 'success' | 'warning' | 'error';
export type LogAction = 'create' | 'update' | 'delete' | 'generate' | 'publish' | 'sync' | 'test';
interface LogEntry {
action: LogAction;
message: string;
entity_type?: string;
entity_id?: string | number;
details?: string;
level?: LogLevel;
site?: number;
}
export async function logWork(entry: LogEntry) {
try {
const client = getDirectusClient();
await client.request(
createItem('work_log', {
action: entry.action,
message: entry.message,
entity_type: entry.entity_type,
entity_id: entry.entity_id?.toString(),
details: entry.details,
level: entry.level || 'info',
site: entry.site,
status: 'completed',
})
);
} catch (error) {
console.error('Failed to log work:', error);
}
}
// Convenience methods
export const logger = {
info: (message: string, details?: Partial<LogEntry>) =>
logWork({ ...details, message, action: details?.action || 'update', level: 'info' }),
success: (message: string, details?: Partial<LogEntry>) =>
logWork({ ...details, message, action: details?.action || 'create', level: 'success' }),
warning: (message: string, details?: Partial<LogEntry>) =>
logWork({ ...details, message, action: details?.action || 'update', level: 'warning' }),
error: (message: string, details?: Partial<LogEntry>) =>
logWork({ ...details, message, action: details?.action || 'update', level: 'error' }),
};

View File

@@ -0,0 +1,71 @@
/**
* Database Transaction Wrapper
* Ensures atomic operations with PostgreSQL
*/
import { getDirectusClient } from '@/lib/directus/client';
import { logger } from '@/lib/utils/logger';
export async function withTransaction<T>(
operation: () => Promise<T>,
options?: {
onError?: (error: Error) => void;
logContext?: string;
}
): Promise<T> {
try {
// Execute operation
const result = await operation();
if (options?.logContext) {
await logger.success(`Transaction completed: ${options.logContext}`);
}
return result;
} catch (error) {
// Log error
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
if (options?.logContext) {
await logger.error(`Transaction failed: ${options.logContext}`, {
details: errorMessage,
});
}
// Call error handler if provided
if (options?.onError && error instanceof Error) {
options.onError(error);
}
throw error;
}
}
// Batch operation wrapper with rate limiting
export async function batchOperation<T>(
items: T[],
operation: (item: T) => Promise<void>,
options?: {
batchSize?: number;
delayMs?: number;
onProgress?: (completed: number, total: number) => void;
}
): Promise<void> {
const batchSize = options?.batchSize || 50;
const delayMs = options?.delayMs || 100;
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
await Promise.all(batch.map(item => operation(item)));
if (options?.onProgress) {
options.onProgress(Math.min(i + batchSize, items.length), items.length);
}
// Delay between batches
if (i + batchSize < items.length && delayMs) {
await new Promise(resolve => setTimeout(resolve, delayMs));
}
}
}

View File

@@ -0,0 +1,134 @@
/**
* Zod Validation Schemas
* Type-safe validation for all collections
*/
import { z } from 'zod';
// Site schema
export const siteSchema = z.object({
id: z.string().uuid().optional(),
name: z.string().min(1, 'Site name required'),
domain: z.string().min(1, 'Domain required'),
domain_aliases: z.array(z.string()).optional(),
settings: z.record(z.any()).optional(),
status: z.enum(['active', 'inactive']),
date_created: z.string().optional(),
date_updated: z.string().optional(),
});
// Collection schema
export const collectionSchema = z.object({
id: z.string().uuid().optional(),
name: z.string().min(1, 'Collection name required'),
status: z.enum(['queued', 'processing', 'complete', 'failed']),
site_id: z.string().uuid('Invalid site ID'),
avatar_id: z.string().uuid('Invalid avatar ID'),
pattern_id: z.string().uuid('Invalid pattern ID'),
geo_cluster_id: z.string().uuid('Invalid geo cluster ID').optional(),
target_keyword: z.string().min(1, 'Keyword required'),
batch_size: z.number().min(1).max(1000),
logs: z.any().optional(),
date_created: z.string().optional(),
});
// Generated article schema
export const articleSchema = z.object({
id: z.string().uuid().optional(),
collection_id: z.string().uuid('Invalid collection ID'),
status: z.enum(['queued', 'generating', 'review', 'approved', 'published', 'failed']),
title: z.string().min(1, 'Title required'),
slug: z.string().min(1, 'Slug required'),
content_html: z.string().optional(),
content_raw: z.string().optional(),
assembly_map: z.object({
pattern_id: z.string(),
block_ids: z.array(z.string()),
variables: z.record(z.string()),
}).optional(),
seo_score: z.number().min(0).max(100).optional(),
geo_city: z.string().optional(),
geo_state: z.string().optional(),
featured_image_url: z.string().url().optional(),
meta_desc: z.string().max(160).optional(),
schema_json: z.any().optional(),
logs: z.any().optional(),
wordpress_post_id: z.number().optional(),
is_published: z.boolean().optional(),
date_created: z.string().optional(),
});
// Content block schema
export const contentBlockSchema = z.object({
id: z.string().uuid().optional(),
category: z.enum(['intro', 'body', 'cta', 'problem', 'solution', 'benefits']),
avatar_id: z.string().uuid('Invalid avatar ID'),
content: z.string().min(1, 'Content required'),
tags: z.array(z.string()).optional(),
usage_count: z.number().optional(),
});
// Pattern schema
export const patternSchema = z.object({
id: z.string().uuid().optional(),
name: z.string().min(1, 'Pattern name required'),
structure_json: z.any(),
execution_order: z.array(z.string()),
preview_template: z.string().optional(),
});
// Avatar schema
export const avatarSchema = z.object({
id: z.string().uuid().optional(),
base_name: z.string().min(1, 'Avatar name required'),
business_niches: z.array(z.string()),
wealth_cluster: z.string(),
});
// Geo cluster schema
export const geoClusterSchema = z.object({
id: z.string().uuid().optional(),
cluster_name: z.string().min(1, 'Cluster name required'),
});
// Spintax validation
export const validateSpintax = (text: string): { valid: boolean; errors: string[] } => {
const errors: string[] = [];
// Check for unbalanced braces
let braceCount = 0;
for (let i = 0; i < text.length; i++) {
if (text[i] === '{') braceCount++;
if (text[i] === '}') braceCount--;
if (braceCount < 0) {
errors.push(`Unbalanced closing brace at position ${i}`);
break;
}
}
if (braceCount > 0) {
errors.push('Unclosed opening braces');
}
// Check for empty options
if (/{[^}]*\|\|[^}]*}/.test(text)) {
errors.push('Empty spintax options found');
}
// Check for orphaned pipes
if (/\|(?![^{]*})/.test(text)) {
errors.push('Pipe character outside spintax block');
}
return {
valid: errors.length === 0,
errors,
};
};
export type Site = z.infer<typeof siteSchema>;
export type Collection = z.infer<typeof collectionSchema>;
export type Article = z.infer<typeof articleSchema>;
export type ContentBlock = z.infer<typeof contentBlockSchema>;
export type Pattern = z.infer<typeof patternSchema>;
export type Avatar = z.infer<typeof avatarSchema>;
export type GeoCluster = z.infer<typeof geoClusterSchema>;

View File

View File

View File

View File

@@ -0,0 +1,138 @@
export interface WPPost {
id: number;
date: string;
slug: string;
status: string;
type: string;
link: string;
title: { rendered: string };
content: { rendered: string };
excerpt: { rendered: string };
}
export class WordPressClient {
private baseUrl: string;
private authHeader: string | null = null;
constructor(domain: string, appPassword?: string) {
// Normalize domain
this.baseUrl = domain.replace(/\/$/, '');
if (!this.baseUrl.startsWith('http')) {
this.baseUrl = `https://${this.baseUrl}`;
}
if (appPassword) {
// Assumes username is 'admin' or handled in the pass string if formatted 'user:pass'
// Usually Application Passwords are just the pwd, requiring a user.
// For now, let's assume the user passes "username:app_password" string or implemented later.
// We'll stick to public GET for now which doesn't need auth for reading content usually.
// If auth is needed:
// this.authHeader = `Basic ${btoa(appPassword)}`;
}
}
async testConnection(): Promise<boolean> {
try {
const res = await fetch(`${this.baseUrl}/wp-json/`);
return res.ok;
} catch (e) {
console.error("WP Connection Failed", e);
return false;
}
}
async getPages(limit = 100): Promise<WPPost[]> {
const url = `${this.baseUrl}/wp-json/wp/v2/pages?per_page=${limit}`;
return this.fetchCollection(url);
}
async getPosts(limit = 100, page = 1): Promise<WPPost[]> {
const url = `${this.baseUrl}/wp-json/wp/v2/posts?per_page=${limit}&page=${page}`;
return this.fetchCollection(url);
}
async getPost(postId: number): Promise<WPPost | null> {
try {
const url = `${this.baseUrl}/wp-json/wp/v2/posts/${postId}`;
const res = await fetch(url);
if (!res.ok) return null;
return await res.json();
} catch (e) {
console.error("Fetch Post Error", e);
return null;
}
}
async getAllPosts(): Promise<WPPost[]> {
let allPosts: WPPost[] = [];
let page = 1;
let totalPages = 1;
// First fetch to get total pages
const url = `${this.baseUrl}/wp-json/wp/v2/posts?per_page=100&page=${page}`;
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`WP API Error: ${res.status}`);
const totalPagesHeader = res.headers.get('X-WP-TotalPages');
if (totalPagesHeader) {
totalPages = parseInt(totalPagesHeader, 10);
}
const data = await res.json();
allPosts = [...allPosts, ...data];
// Loop remaining pages
// Process in parallel chunks if too many, but for now sequential is safer to avoid rate limits
// or perform simple Promise.all for batches.
// Let's do batches of 5 to speed it up.
const remainingPages = [];
for (let p = 2; p <= totalPages; p++) {
remainingPages.push(p);
}
// Batch fetch
const batchSize = 5;
for (let i = 0; i < remainingPages.length; i += batchSize) {
const batch = remainingPages.slice(i, i + batchSize);
const promises = batch.map(p =>
fetch(`${this.baseUrl}/wp-json/wp/v2/posts?per_page=100&page=${p}`)
.then(r => r.json())
);
const results = await Promise.all(promises);
results.forEach(posts => {
allPosts = [...allPosts, ...posts];
});
}
} catch (e) {
console.error("Fetch Error", e);
throw e;
}
return allPosts;
}
async getCategories(): Promise<any[]> {
// Fetch all categories
return this.fetchCollection(`${this.baseUrl}/wp-json/wp/v2/categories?per_page=100`);
}
async getTags(): Promise<any[]> {
// Fetch all tags
return this.fetchCollection(`${this.baseUrl}/wp-json/wp/v2/tags?per_page=100`);
}
private async fetchCollection(url: string): Promise<any[]> {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`WP API Error: ${res.status}`);
return await res.json();
} catch (e) {
console.error("Fetch Error", e);
throw e;
}
}
}

View File

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

View File

@@ -0,0 +1,180 @@
---
// src/pages/admin/db-console.astro
import Layout from '@/layouts/BaseLayout.astro'; // Assuming BaseLayout exists, or check existing layout
import { MECHANIC_OPS } from '@/lib/db/mechanic';
// Server-side Logic (runs on build or request in SSR)
let health;
try {
health = await MECHANIC_OPS.getHealth();
} catch (e) {
console.error('Failed to get health:', e);
health = {
size: 'Error',
connections: [],
cache: { ratio: 0 }
};
}
const activeConnections = health.connections.find((c: any) => c.state === 'active')?.active || 0;
const idleConnections = health.connections.find((c: any) => c.state === 'idle')?.active || 0;
const token = import.meta.env.GOD_MODE_TOKEN || '';
---
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<title>DB Command Center - Valhalla</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
zinc: { 800: '#27272a', 900: '#18181b' }
}
}
}
}
</script>
</head>
<body class="bg-zinc-900 text-white min-h-screen">
<div class="p-8 space-y-8 max-w-7xl mx-auto">
<header class="flex justify-between items-center mb-8">
<div>
<h1 class="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-yellow-400 to-red-500">
Valhalla DB Command Center
</h1>
<p class="text-gray-400">Direct PostgreSQL Interface</p>
</div>
<a href="/" class="text-gray-400 hover:text-white">Back to Dashboard</a>
</header>
<!-- Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-zinc-800 p-6 rounded-lg border-l-4 border-green-500 shadow-lg">
<h3 class="text-gray-400 text-sm uppercase font-semibold">DB Size</h3>
<p class="text-4xl font-bold mt-2">{health.size}</p>
</div>
<div class="bg-zinc-800 p-6 rounded-lg border-l-4 border-blue-500 shadow-lg">
<h3 class="text-gray-400 text-sm uppercase font-semibold">Connections</h3>
<div class="flex items-end gap-2 mt-2">
<p class="text-4xl font-bold">{activeConnections}</p>
<span class="text-xl text-gray-500">active</span>
</div>
<p class="text-sm text-gray-500 mt-1">{idleConnections} idle</p>
</div>
<div class="bg-zinc-800 p-6 rounded-lg border-l-4 border-purple-500 shadow-lg">
<h3 class="text-gray-400 text-sm uppercase font-semibold">Cache Efficiency</h3>
<p class="text-4xl font-bold mt-2">{Math.round(health.cache.ratio * 100)}%</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Emergency Controls -->
<div class="bg-zinc-800/50 border border-zinc-700 p-6 rounded-xl">
<h2 class="text-xl mb-6 text-red-400 flex items-center gap-2">
<span>🚨</span> Emergency Fixes
</h2>
<div class="space-y-4">
<button onclick="runOp('vacuum')" class="w-full group relative overflow-hidden bg-yellow-600 hover:bg-yellow-500 transition-all p-4 rounded-lg font-bold text-left flex justify-between items-center">
<div>
<div class="text-white">Run Vacuum</div>
<div class="text-xs text-yellow-200 opacity-70">Optimize dead rows</div>
</div>
<span class="text-2xl opacity-50 group-hover:opacity-100">🧹</span>
</button>
<button onclick="runOp('reindex')" class="w-full group relative overflow-hidden bg-orange-600 hover:bg-orange-500 transition-all p-4 rounded-lg font-bold text-left flex justify-between items-center">
<div>
<div class="text-white">Reindex Database</div>
<div class="text-xs text-orange-200 opacity-70">Fix corrupted indexes</div>
</div>
<span class="text-2xl opacity-50 group-hover:opacity-100">📑</span>
</button>
<button onclick="runOp('kill_locks')" class="w-full group relative overflow-hidden bg-red-700 hover:bg-red-600 transition-all p-4 rounded-lg font-bold text-left flex justify-between items-center">
<div>
<div class="text-white">Kill Stuck Queries</div>
<div class="text-xs text-red-200 opacity-70">Terminate > 5min processes</div>
</div>
<span class="text-2xl opacity-50 group-hover:opacity-100">💀</span>
</button>
</div>
</div>
<!-- Throttles -->
<div class="lg:col-span-2 bg-zinc-800/50 border border-zinc-700 p-6 rounded-xl">
<h2 class="text-xl mb-6 text-blue-400 flex items-center gap-2">
<span>🎚️</span> Factory Throttles
</h2>
<form id="throttleForm" class="space-y-8">
<div class="space-y-2">
<div class="flex justify-between">
<label class="font-medium text-gray-300">Batch Size (Items per Chunk)</label>
<span class="text-blue-400 font-mono" id="batchOutput">100</span>
</div>
<input type="range" min="10" max="1000" value="100" step="10"
class="w-full h-2 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
oninput="document.getElementById('batchOutput').textContent = this.value + ' rows'">
<p class="text-sm text-gray-500">Higher values use more memory but process faster.</p>
</div>
<div class="space-y-2">
<div class="flex justify-between">
<label class="font-medium text-gray-300">Concurrency (Parallel Workers)</label>
<span class="text-blue-400 font-mono" id="concurrencyOutput">5</span>
</div>
<input type="range" min="1" max="50" value="5" step="1"
class="w-full h-2 bg-zinc-700 rounded-lg appearance-none cursor-pointer accent-blue-500"
oninput="document.getElementById('concurrencyOutput').textContent = this.value + ' threads'">
<p class="text-sm text-gray-500">Higher values allow more simultaneous DB connections.</p>
</div>
<div class="pt-4 border-t border-zinc-700">
<button type="button" class="bg-blue-600 hover:bg-blue-500 text-white px-8 py-3 rounded-lg font-bold transition-colors">
Update Runtime Engine
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Hidden Token for Client Script -->
<div id="god-token" data-token={token} style="display:none;"></div>
<script is:inline>
async function runOp(operation) {
const token = document.getElementById('god-token')?.dataset.token;
if(!confirm(`⚠️ Are you sure you want to RUN ${operation.toUpperCase()}? This may affect database performance.`)) return;
try {
const res = await fetch('/api/god/db-ops', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ operation })
});
const text = await res.text();
if (res.ok) {
alert('✅ Success: ' + text);
location.reload();
} else {
alert('❌ Error: ' + text);
}
} catch (err) {
alert('Connection Error: ' + err.message);
}
}
</script>
</body>
</html>

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 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,28 @@
---
import Layout from '@/layouts/AdminLayout.astro';
import SiteDashboard from '@/components/admin/sites/SiteDashboard';
import { Button } from '@/components/ui/button';
import { ArrowLeft } from 'lucide-react';
const { siteId } = Astro.params;
---
<Layout title="Manage Site | Spark Launchpad">
<div class="h-screen flex flex-col">
<div class="border-b border-zinc-800 bg-zinc-950 p-4 flex items-center gap-4">
<a href="/admin/sites">
<Button variant="ghost" size="icon" class="text-zinc-400 hover:text-white">
<ArrowLeft className="h-4 w-4" />
</Button>
</a>
<div>
<h1 class="text-xl font-bold text-white tracking-tight">Site Management</h1>
<p class="text-xs text-zinc-500 font-mono">ID: {siteId}</p>
</div>
</div>
<div class="flex-1 overflow-auto p-8">
<SiteDashboard client:only="react" siteId={siteId!} />
</div>
</div>
</Layout>

View File

@@ -0,0 +1,10 @@
---
import Layout from '@/layouts/AdminLayout.astro';
import PageEditor from '@/components/admin/sites/PageEditor';
const { pageId } = Astro.params;
---
<Layout title="Page Editor | Spark Launchpad" hideSidebar={true}>
<PageEditor client:only="react" pageId={pageId!} onBack={() => history.back()} />
</Layout>

View File

@@ -0,0 +1,18 @@
---
import Layout from '@/layouts/AdminLayout.astro';
import WPImporter from '@/components/admin/wordpress/WPImporter';
---
<Layout title="Import WordPress Site">
<div class="p-6 space-y-6">
<div class="flex items-center gap-2 text-slate-400 text-sm">
<a href="/admin/sites" class="hover:text-blue-400">Sites</a>
<span>/</span>
<span class="text-white">Import Wizard</span>
</div>
<h1 class="text-3xl font-bold text-slate-100">Content Import & Refactor</h1>
<WPImporter client:load />
</div>
</Layout>

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