God Mode Valhalla: Initial Standalone Commit
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.DS_Store
|
||||
.astro
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
coverage
|
||||
.vscode
|
||||
42
Dockerfile
Normal file
42
Dockerfile
Normal 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
23
astro.config.mjs
Normal 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']
|
||||
}
|
||||
}
|
||||
});
|
||||
22
migrations/01_init_sites.sql
Normal file
22
migrations/01_init_sites.sql
Normal 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
19880
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
99
package.json
Normal file
99
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
61
src/components/admin/SystemStatus.tsx
Normal file
61
src/components/admin/SystemStatus.tsx
Normal 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';
|
||||
}
|
||||
}
|
||||
155
src/components/admin/SystemStatusBar.tsx
Normal file
155
src/components/admin/SystemStatusBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/components/admin/posts/PostEditor.tsx
Normal file
47
src/components/admin/posts/PostEditor.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
// @ts-nocheck
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getDirectusClient, readItem } from '@/lib/directus/client';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export default function PostEditor({ id }: { id: string }) {
|
||||
const [post, setPost] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const client = getDirectusClient();
|
||||
try {
|
||||
const data = await client.request(readItem('posts', id));
|
||||
setPost(data);
|
||||
} catch (e) { console.error(e); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
if (id) load();
|
||||
}, [id]);
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (!post) return <div>Post not found</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl">
|
||||
<Card className="bg-slate-800 border-slate-700 p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Post Title</Label>
|
||||
<Input value={post.title} className="bg-slate-900 border-slate-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Slug</Label>
|
||||
<Input value={post.slug} className="bg-slate-900 border-slate-700" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Content (Markdown/HTML)</Label>
|
||||
<textarea className="w-full bg-slate-900 border-slate-700 rounded p-3 min-h-[300px]" value={post.content || ''}></textarea>
|
||||
</div>
|
||||
<Button className="mt-4">Save Changes</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
src/components/admin/posts/PostList.tsx
Normal file
68
src/components/admin/posts/PostList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
src/components/admin/sites/NavigationManager.tsx
Normal file
141
src/components/admin/sites/NavigationManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
257
src/components/admin/sites/PageEditor.tsx
Normal file
257
src/components/admin/sites/PageEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/components/admin/sites/SiteDashboard.tsx
Normal file
41
src/components/admin/sites/SiteDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
221
src/components/admin/sites/SiteEditor.tsx
Normal file
221
src/components/admin/sites/SiteEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
87
src/components/admin/sites/SiteList.tsx
Normal file
87
src/components/admin/sites/SiteList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
src/components/admin/sites/SitePagesManager.tsx
Normal file
166
src/components/admin/sites/SitePagesManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
src/components/admin/sites/SitesManager.tsx
Normal file
178
src/components/admin/sites/SitesManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
src/components/admin/sites/ThemeSettings.tsx
Normal file
116
src/components/admin/sites/ThemeSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
src/components/debug/DebugToolbar.tsx
Normal file
178
src/components/debug/DebugToolbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
src/components/engine/BlockRenderer.tsx
Normal file
39
src/components/engine/BlockRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/components/engine/blocks/Content.tsx
Normal file
13
src/components/engine/blocks/Content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/components/engine/blocks/Features.tsx
Normal file
40
src/components/engine/blocks/Features.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/components/engine/blocks/Hero.tsx
Normal file
38
src/components/engine/blocks/Hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/components/providers/CoreProviders.tsx
Normal file
21
src/components/providers/CoreProviders.tsx
Normal 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 />;
|
||||
}
|
||||
0
src/components/testing/ContentTester.tsx
Normal file
0
src/components/testing/ContentTester.tsx
Normal file
0
src/components/testing/DuplicateDetector.tsx
Normal file
0
src/components/testing/DuplicateDetector.tsx
Normal file
0
src/components/testing/GrammarCheck.tsx
Normal file
0
src/components/testing/GrammarCheck.tsx
Normal file
0
src/components/testing/LinkChecker.tsx
Normal file
0
src/components/testing/LinkChecker.tsx
Normal file
0
src/components/testing/SEOValidator.tsx
Normal file
0
src/components/testing/SEOValidator.tsx
Normal file
0
src/components/testing/SchemaValidator.tsx
Normal file
0
src/components/testing/SchemaValidator.tsx
Normal file
109
src/components/testing/TestRunner.tsx
Normal file
109
src/components/testing/TestRunner.tsx
Normal 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;
|
||||
29
src/components/ui/UnderConstruction.tsx
Normal file
29
src/components/ui/UnderConstruction.tsx
Normal 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;
|
||||
43
src/components/ui/alert-dialog.tsx
Normal file
43
src/components/ui/alert-dialog.tsx
Normal 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,
|
||||
}
|
||||
35
src/components/ui/badge.tsx
Normal file
35
src/components/ui/badge.tsx
Normal 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 }
|
||||
55
src/components/ui/button.tsx
Normal file
55
src/components/ui/button.tsx
Normal 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 }
|
||||
78
src/components/ui/card.tsx
Normal file
78
src/components/ui/card.tsx
Normal 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 }
|
||||
20
src/components/ui/checkbox.tsx
Normal file
20
src/components/ui/checkbox.tsx
Normal 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 }
|
||||
31
src/components/ui/dialog.tsx
Normal file
31
src/components/ui/dialog.tsx
Normal 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 }
|
||||
200
src/components/ui/dropdown-menu.tsx
Normal file
200
src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
22
src/components/ui/input.tsx
Normal file
22
src/components/ui/input.tsx
Normal 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 }
|
||||
12
src/components/ui/label.tsx
Normal file
12
src/components/ui/label.tsx
Normal 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 }
|
||||
14
src/components/ui/progress.tsx
Normal file
14
src/components/ui/progress.tsx
Normal 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 }
|
||||
20
src/components/ui/select.tsx
Normal file
20
src/components/ui/select.tsx
Normal 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 }
|
||||
20
src/components/ui/slider.tsx
Normal file
20
src/components/ui/slider.tsx
Normal 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 }
|
||||
22
src/components/ui/spinner.tsx
Normal file
22
src/components/ui/spinner.tsx
Normal 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 }
|
||||
33
src/components/ui/switch.tsx
Normal file
33
src/components/ui/switch.tsx
Normal 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
119
src/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
52
src/components/ui/tabs.tsx
Normal file
52
src/components/ui/tabs.tsx
Normal 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 }
|
||||
19
src/components/ui/textarea.tsx
Normal file
19
src/components/ui/textarea.tsx
Normal 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
1
src/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
238
src/layouts/AdminLayout.astro
Normal file
238
src/layouts/AdminLayout.astro
Normal 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>
|
||||
0
src/lib/analytics/metrics.ts
Normal file
0
src/lib/analytics/metrics.ts
Normal file
0
src/lib/analytics/tracking.ts
Normal file
0
src/lib/analytics/tracking.ts
Normal file
44
src/lib/assembler/data.ts
Normal file
44
src/lib/assembler/data.ts
Normal 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.
|
||||
}
|
||||
68
src/lib/assembler/engine.ts
Normal file
68
src/lib/assembler/engine.ts
Normal 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;
|
||||
}
|
||||
0
src/lib/assembler/quality.ts
Normal file
0
src/lib/assembler/quality.ts
Normal file
0
src/lib/assembler/seo.ts
Normal file
0
src/lib/assembler/seo.ts
Normal file
0
src/lib/assembler/spintax.ts
Normal file
0
src/lib/assembler/spintax.ts
Normal file
0
src/lib/assembler/variables.ts
Normal file
0
src/lib/assembler/variables.ts
Normal file
214
src/lib/cartesian/CartesianEngine.ts
Normal file
214
src/lib/cartesian/CartesianEngine.ts
Normal 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 {};
|
||||
}
|
||||
}
|
||||
}
|
||||
49
src/lib/cartesian/GrammarEngine.ts
Normal file
49
src/lib/cartesian/GrammarEngine.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
|
||||
/**
|
||||
* GrammarEngine
|
||||
* Resolves grammar tokens like [[PRONOUN]], [[ISARE]] based on avatar variants.
|
||||
*/
|
||||
export class GrammarEngine {
|
||||
/**
|
||||
* Resolve grammar tokens in text.
|
||||
* @param text Text containing [[TOKEN]] syntax
|
||||
* @param variant The avatar variant object (e.g. { pronoun: "he", isare: "is" })
|
||||
* @param variables Optional extra variables for function tokens like [[A_AN:{{NICHE}}]]
|
||||
*/
|
||||
static resolve(text: string, variant: Record<string, string>): string {
|
||||
if (!text) return '';
|
||||
let resolved = text;
|
||||
|
||||
// 1. Simple replacement from variant map
|
||||
// Matches [[KEY]]
|
||||
resolved = resolved.replace(/\[\[([A-Z_]+)\]\]/g, (match, key) => {
|
||||
const lowerKey = key.toLowerCase();
|
||||
if (variant[lowerKey]) {
|
||||
return variant[lowerKey];
|
||||
}
|
||||
return match; // Return original if not found
|
||||
});
|
||||
|
||||
// 2. Handling A/An logic: [[A_AN:Word]]
|
||||
resolved = resolved.replace(/\[\[A_AN:(.*?)\]\]/g, (match, content) => {
|
||||
return GrammarEngine.a_an(content);
|
||||
});
|
||||
|
||||
// 3. Capitalization: [[CAP:word]]
|
||||
resolved = resolved.replace(/\[\[CAP:(.*?)\]\]/g, (match, content) => {
|
||||
return content.charAt(0).toUpperCase() + content.slice(1);
|
||||
});
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
static a_an(word: string): string {
|
||||
const vowels = ['a', 'e', 'i', 'o', 'u'];
|
||||
const firstChar = word.trim().charAt(0).toLowerCase();
|
||||
// Simple heuristic
|
||||
if (vowels.includes(firstChar)) {
|
||||
return `an ${word}`;
|
||||
}
|
||||
return `a ${word}`;
|
||||
}
|
||||
}
|
||||
60
src/lib/cartesian/HTMLRenderer.ts
Normal file
60
src/lib/cartesian/HTMLRenderer.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
|
||||
/**
|
||||
* HTMLRenderer (Assembler)
|
||||
* Wraps raw content blocks in formatted HTML.
|
||||
*/
|
||||
export class HTMLRenderer {
|
||||
/**
|
||||
* Render a full article from blocks.
|
||||
* @param blocks Array of processed content blocks objects
|
||||
* @returns Full HTML string
|
||||
*/
|
||||
static renderArticle(blocks: any[]): string {
|
||||
return blocks.map(block => this.renderBlock(block)).join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single block based on its structure.
|
||||
*/
|
||||
static renderBlock(block: any): string {
|
||||
let html = '';
|
||||
|
||||
// Title
|
||||
if (block.title) {
|
||||
html += `<h2>${block.title}</h2>\n`;
|
||||
}
|
||||
|
||||
// Hook
|
||||
if (block.hook) {
|
||||
html += `<p class="lead"><strong>${block.hook}</strong></p>\n`;
|
||||
}
|
||||
|
||||
// Pains (Unordered List)
|
||||
if (block.pains && block.pains.length > 0) {
|
||||
html += `<ul>\n${block.pains.map((p: string) => ` <li>${p}</li>`).join('\n')}\n</ul>\n`;
|
||||
}
|
||||
|
||||
// Solutions (Paragraphs or Ordered List)
|
||||
if (block.solutions && block.solutions.length > 0) {
|
||||
// Configurable, defaulting to paragraphs for flow
|
||||
html += block.solutions.map((s: string) => `<p>${s}</p>`).join('\n') + '\n';
|
||||
}
|
||||
|
||||
// Value Points (Checkmark List style usually)
|
||||
if (block.value_points && block.value_points.length > 0) {
|
||||
html += `<ul class="value-points">\n${block.value_points.map((v: string) => ` <li>✅ ${v}</li>`).join('\n')}\n</ul>\n`;
|
||||
}
|
||||
|
||||
// Raw Content (from Spintax/Components)
|
||||
if (block.content) {
|
||||
html += `<div class="block-content">\n${block.content}\n</div>\n`;
|
||||
}
|
||||
|
||||
// CTA
|
||||
if (block.cta) {
|
||||
html += `<div class="cta-box"><p>${block.cta}</p></div>\n`;
|
||||
}
|
||||
|
||||
return `<section class="content-block" id="${block.id || ''}">\n${html}</section>`;
|
||||
}
|
||||
}
|
||||
15
src/lib/cartesian/MetadataGenerator.ts
Normal file
15
src/lib/cartesian/MetadataGenerator.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
|
||||
/**
|
||||
* MetadataGenerator
|
||||
* Auto-generates SEO titles and descriptions.
|
||||
*/
|
||||
export class MetadataGenerator {
|
||||
static generateTitle(niche: string, city: string, state: string): string {
|
||||
// Simple formula for now - can be expanded to use patterns
|
||||
return `Top ${niche} Services in ${city}, ${state} | Verified Experts`;
|
||||
}
|
||||
|
||||
static generateDescription(niche: string, city: string): string {
|
||||
return `Looking for the best ${niche} in ${city}? We provide top-rated solutions tailored for your business needs. Get a free consultation today.`;
|
||||
}
|
||||
}
|
||||
42
src/lib/cartesian/SpintaxParser.ts
Normal file
42
src/lib/cartesian/SpintaxParser.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
|
||||
/**
|
||||
* SpintaxParser
|
||||
* Handles recursive parsing of {option1|option2} syntax.
|
||||
*/
|
||||
export class SpintaxParser {
|
||||
/**
|
||||
* Parse a string containing spintax.
|
||||
* Supports nested spintax like {Hi|Hello {World|Friend}}
|
||||
* @param text The text with spintax
|
||||
* @returns The parsed text with one option selected per block
|
||||
*/
|
||||
static parse(text: string): string {
|
||||
if (!text) return '';
|
||||
|
||||
// Regex to find the innermost spintax block: {([^{}]*)}
|
||||
// We execute this recursively until no braces remain.
|
||||
let parsed = text;
|
||||
const regex = /\{([^{}]+)\}/g;
|
||||
|
||||
while (regex.test(parsed)) {
|
||||
parsed = parsed.replace(regex, (match, content) => {
|
||||
const options = content.split('|');
|
||||
const randomOption = options[Math.floor(Math.random() * options.length)];
|
||||
return randomOption;
|
||||
});
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count total variations in a spintax string.
|
||||
* (Simplified estimate for preview calculator)
|
||||
*/
|
||||
static countVariations(text: string): number {
|
||||
// Basic implementation for complexity estimation
|
||||
// Real count requiring parsing tree is complex,
|
||||
// this is a placeholder if needed for UI later.
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
33
src/lib/cartesian/UniquenessManager.ts
Normal file
33
src/lib/cartesian/UniquenessManager.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
|
||||
import module from 'node:crypto';
|
||||
const { createHash } = module;
|
||||
|
||||
/**
|
||||
* UniquenessManager
|
||||
* Handles content hashing to prevent duplicate generation.
|
||||
*/
|
||||
export class UniquenessManager {
|
||||
/**
|
||||
* Generate a unique hash for a specific combination.
|
||||
* Format: {SiteID}_{AvatarID}_{Niche}_{City}_{PatternID}
|
||||
*/
|
||||
static generateHash(siteId: string, avatarId: string, niche: string, city: string, patternId: string): string {
|
||||
const raw = `${siteId}_${avatarId}_${niche}_${city}_${patternId}`;
|
||||
return createHash('md5').update(raw).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a hash already exists in the database.
|
||||
* (Placeholder logic - real implementation queries Directus)
|
||||
*/
|
||||
static async checkExists(client: any, hash: string): Promise<boolean> {
|
||||
try {
|
||||
// This would be a Directus query
|
||||
// const res = await client.request(readItems('generated_articles', { filter: { generation_hash: { _eq: hash } }, limit: 1 }));
|
||||
// return res.length > 0;
|
||||
return false; // For now
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
107
src/lib/collections/config.ts
Normal file
107
src/lib/collections/config.ts
Normal 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
16
src/lib/db.ts
Normal 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
55
src/lib/db/mechanic.ts
Normal 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.`;
|
||||
}
|
||||
}
|
||||
};
|
||||
12
src/lib/directus-enhanced.ts
Normal file
12
src/lib/directus-enhanced.ts
Normal 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
273
src/lib/directus/client.ts
Normal 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 };
|
||||
}
|
||||
319
src/lib/directus/fetchers.ts
Normal file
319
src/lib/directus/fetchers.ts
Normal 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
236
src/lib/godMode.ts
Normal 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;
|
||||
38
src/lib/intelligence/types.ts
Normal file
38
src/lib/intelligence/types.ts
Normal 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>;
|
||||
}
|
||||
51
src/lib/queue/BatchProcessor.ts
Normal file
51
src/lib/queue/BatchProcessor.ts
Normal 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
44
src/lib/queue/config.ts
Normal 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
10
src/lib/react-query.ts
Normal 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
404
src/lib/schemas.ts
Normal 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
361
src/lib/seo/cartesian.ts
Normal 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;
|
||||
}
|
||||
176
src/lib/seo/image-generator.ts
Normal file
176
src/lib/seo/image-generator.ts
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
195
src/lib/seo/velocity-scheduler.ts
Normal file
195
src/lib/seo/velocity-scheduler.ts
Normal 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
95
src/lib/testing/seo.ts
Normal 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
138
src/lib/theme/config.ts
Normal 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
7
src/lib/utils.ts
Normal 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))
|
||||
}
|
||||
103
src/lib/utils/circuit-breaker.ts
Normal file
103
src/lib/utils/circuit-breaker.ts
Normal 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
64
src/lib/utils/dry-run.ts
Normal 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
56
src/lib/utils/logger.ts
Normal 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' }),
|
||||
};
|
||||
71
src/lib/utils/transactions.ts
Normal file
71
src/lib/utils/transactions.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
134
src/lib/validation/schemas.ts
Normal file
134
src/lib/validation/schemas.ts
Normal 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>;
|
||||
0
src/lib/variables/context.ts
Normal file
0
src/lib/variables/context.ts
Normal file
0
src/lib/variables/interpolation.ts
Normal file
0
src/lib/variables/interpolation.ts
Normal file
0
src/lib/variables/templates.ts
Normal file
0
src/lib/variables/templates.ts
Normal file
138
src/lib/wordpress/WordPressClient.ts
Normal file
138
src/lib/wordpress/WordPressClient.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
9
src/pages/admin/content-factory.astro
Normal file
9
src/pages/admin/content-factory.astro
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
import ContentFactoryDashboard from '@/components/admin/content/ContentFactoryDashboard';
|
||||
---
|
||||
<Layout title="Factory Command Center">
|
||||
<div class="p-8">
|
||||
<ContentFactoryDashboard client:load />
|
||||
</div>
|
||||
</Layout>
|
||||
180
src/pages/admin/db-console.astro
Normal file
180
src/pages/admin/db-console.astro
Normal 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>
|
||||
20
src/pages/admin/posts/[id].astro
Normal file
20
src/pages/admin/posts/[id].astro
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
import PostEditor from '@/components/admin/posts/PostEditor';
|
||||
|
||||
const { id } = Astro.params;
|
||||
---
|
||||
|
||||
<Layout title="Edit Post">
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<a href="/admin/posts" class="text-slate-400 hover:text-white flex items-center gap-2 mb-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
|
||||
Back to Posts
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold text-slate-100">Edit Post</h1>
|
||||
</div>
|
||||
|
||||
<PostEditor id={id} client:only="react" />
|
||||
</div>
|
||||
</Layout>
|
||||
21
src/pages/admin/posts/index.astro
Normal file
21
src/pages/admin/posts/index.astro
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
import PostList from '@/components/admin/posts/PostList';
|
||||
---
|
||||
|
||||
<Layout title="Post Management">
|
||||
<div class="p-6 space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-slate-100">Posts</h1>
|
||||
<p class="text-slate-400">Manage blog posts and articles.</p>
|
||||
</div>
|
||||
<a href="/admin/posts/new" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg font-medium transition-colors flex items-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /></svg>
|
||||
New Post
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<PostList client:load />
|
||||
</div>
|
||||
</Layout>
|
||||
20
src/pages/admin/sites/[id].astro
Normal file
20
src/pages/admin/sites/[id].astro
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import Layout from '@/layouts/AdminLayout.astro';
|
||||
import SiteEditor from '@/components/admin/sites/SiteEditor';
|
||||
|
||||
const { id } = Astro.params;
|
||||
---
|
||||
|
||||
<Layout title="Edit Site">
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<a href="/admin/sites" class="text-slate-400 hover:text-white flex items-center gap-2 mb-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
|
||||
Back to Sites
|
||||
</a>
|
||||
<h1 class="text-3xl font-bold text-slate-100">Configure Site</h1>
|
||||
</div>
|
||||
|
||||
<SiteEditor id={id} client:only="react" />
|
||||
</div>
|
||||
</Layout>
|
||||
28
src/pages/admin/sites/[siteId]/index.astro
Normal file
28
src/pages/admin/sites/[siteId]/index.astro
Normal 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>
|
||||
10
src/pages/admin/sites/editor/[pageId].astro
Normal file
10
src/pages/admin/sites/editor/[pageId].astro
Normal 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>
|
||||
18
src/pages/admin/sites/import.astro
Normal file
18
src/pages/admin/sites/import.astro
Normal 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
Reference in New Issue
Block a user