Initial commit: Spark Platform with Cartesian SEO Engine

This commit is contained in:
cawcenter
2025-12-11 23:21:35 -05:00
commit abd964a745
68 changed files with 7960 additions and 0 deletions

View File

@@ -0,0 +1,201 @@
---
interface Props {
title: string;
}
const { title } = Astro.props;
const currentPath = Astro.url.pathname;
const navItems = [
{ href: '/admin', label: 'Dashboard', icon: 'home' },
{ href: '/admin/pages', label: 'Pages', icon: 'file' },
{ href: '/admin/posts', label: 'Posts', icon: 'edit' },
{ href: '/admin/seo/campaigns', label: 'SEO Campaigns', icon: 'target' },
{ href: '/admin/seo/articles', label: 'Generated Articles', icon: 'newspaper' },
{ href: '/admin/seo/fragments', label: 'Content Fragments', icon: 'puzzle' },
{ href: '/admin/seo/headlines', label: 'Headlines', icon: 'heading' },
{ href: '/admin/media/templates', label: 'Image Templates', icon: 'image' },
{ href: '/admin/locations', label: 'Locations', icon: 'map' },
{ href: '/admin/leads', label: 'Leads', icon: 'users' },
{ href: '/admin/settings', label: 'Settings', icon: 'settings' },
];
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">
<div class="p-6 border-b border-gray-800">
<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-1 overflow-y-auto">
{navItems.map((item) => (
<a
href={item.href}
class={`flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${
isActive(item.href)
? 'bg-primary/20 text-primary'
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
}`}
>
<span class="w-5 h-5">
{item.icon === 'home' && (
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
)}
{item.icon === 'file' && (
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
)}
{item.icon === 'edit' && (
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
)}
{item.icon === 'target' && (
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
)}
{item.icon === 'newspaper' && (
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z" />
</svg>
)}
{item.icon === 'puzzle' && (
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4a2 2 0 114 0v1a1 1 0 001 1h3a1 1 0 011 1v3a1 1 0 01-1 1h-1a2 2 0 100 4h1a1 1 0 011 1v3a1 1 0 01-1 1h-3a1 1 0 01-1-1v-1a2 2 0 10-4 0v1a1 1 0 01-1 1H7a1 1 0 01-1-1v-3a1 1 0 00-1-1H4a2 2 0 110-4h1a1 1 0 001-1V7a1 1 0 011-1h3a1 1 0 001-1V4z" />
</svg>
)}
{item.icon === 'heading' && (
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7" />
</svg>
)}
{item.icon === 'image' && (
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
)}
{item.icon === 'map' && (
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
)}
{item.icon === 'users' && (
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
)}
{item.icon === 'settings' && (
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)}
</span>
<span class="font-medium">{item.label}</span>
</a>
))}
</nav>
<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">
<slot />
</main>
</div>
</body>
</html>

View File

@@ -0,0 +1,271 @@
---
import type { Globals, Navigation } from '@/types/schema';
interface Props {
title: string;
description?: string;
image?: string;
globals?: Globals;
navigation?: Navigation[];
canonical?: string;
}
const {
title,
description,
image,
globals,
navigation = [],
canonical
} = Astro.props;
const siteUrl = Astro.url.origin;
const currentPath = Astro.url.pathname;
const fullTitle = globals?.site_name ? `${title} | ${globals.site_name}` : title;
const metaDescription = description || globals?.site_tagline || '';
const ogImage = image || globals?.logo || '';
---
<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- SEO Meta -->
<title>{fullTitle}</title>
<meta name="description" content={metaDescription} />
{canonical && <link rel="canonical" href={canonical} />}
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={metaDescription} />
{ogImage && <meta property="og:image" content={ogImage} />}
<meta property="og:url" content={siteUrl + currentPath} />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={metaDescription} />
{ogImage && <meta name="twitter:image" content={ogImage} />}
<!-- Favicon -->
{globals?.favicon ? (
<link rel="icon" href={globals.favicon} />
) : (
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
)}
<!-- Preconnect to Directus -->
<link rel="preconnect" href={import.meta.env.PUBLIC_DIRECTUS_URL} />
<!-- Head Scripts -->
{globals?.scripts_head && <Fragment set:html={globals.scripts_head} />}
<!-- Styles -->
<style is:global>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--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%;
}
* {
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 flex-col antialiased">
<!-- Header -->
<header class="sticky top-0 z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-lg border-b border-gray-200 dark:border-gray-800">
<div class="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<a href="/" class="flex items-center gap-3">
{globals?.logo ? (
<img src={globals.logo} alt={globals.site_name || 'Logo'} class="h-8 w-auto" />
) : (
<span class="text-xl font-bold text-gray-900 dark:text-white">
{globals?.site_name || 'Spark'}
</span>
)}
</a>
<nav class="hidden md:flex items-center gap-8">
{navigation.filter(n => !n.parent).map((item) => (
<a
href={item.url}
target={item.target || '_self'}
class={`text-sm font-medium transition-colors hover:text-primary ${
currentPath === item.url
? 'text-primary'
: 'text-gray-600 dark:text-gray-400'
}`}
>
{item.label}
</a>
))}
</nav>
<div class="flex items-center gap-4">
<a
href="/contact"
class="hidden md:inline-flex px-4 py-2 bg-primary text-white rounded-lg font-medium text-sm hover:bg-primary/90 transition-colors"
>
Get Started
</a>
<!-- Mobile menu button -->
<button
id="mobile-menu-btn"
class="md:hidden p-2 text-gray-600 dark:text-gray-400"
aria-label="Toggle menu"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
</div>
<!-- Mobile menu -->
<div id="mobile-menu" class="hidden md:hidden border-t border-gray-200 dark:border-gray-800">
<nav class="px-6 py-4 space-y-3">
{navigation.filter(n => !n.parent).map((item) => (
<a
href={item.url}
target={item.target || '_self'}
class="block py-2 text-gray-600 dark:text-gray-400 hover:text-primary"
>
{item.label}
</a>
))}
<a
href="/contact"
class="block py-2 text-primary font-medium"
>
Get Started
</a>
</nav>
</div>
</header>
<!-- Main Content -->
<main class="flex-1">
<slot />
</main>
<!-- Footer -->
<footer class="bg-gray-900 text-white">
<div class="max-w-7xl mx-auto px-6 py-12">
<div class="grid md:grid-cols-4 gap-8">
<div class="md:col-span-2">
{globals?.logo ? (
<img src={globals.logo} alt={globals.site_name || 'Logo'} class="h-8 w-auto mb-4 brightness-0 invert" />
) : (
<span class="text-2xl font-bold mb-4 block">
{globals?.site_name || 'Spark Platform'}
</span>
)}
<p class="text-gray-400 max-w-md">
{globals?.site_tagline || 'Building the future, one page at a time.'}
</p>
</div>
<div>
<h4 class="font-semibold mb-4">Quick Links</h4>
<nav class="space-y-2">
{navigation.slice(0, 5).map((item) => (
<a
href={item.url}
class="block text-gray-400 hover:text-white transition-colors"
>
{item.label}
</a>
))}
</nav>
</div>
<div>
<h4 class="font-semibold mb-4">Connect</h4>
<div class="flex gap-4">
{globals?.social_links?.map((social) => (
<a
href={social.url}
target="_blank"
rel="noopener noreferrer"
class="text-gray-400 hover:text-white transition-colors"
>
{social.platform}
</a>
))}
</div>
</div>
</div>
<div class="border-t border-gray-800 mt-12 pt-8 text-center text-gray-400 text-sm">
{globals?.footer_text || `© ${new Date().getFullYear()} All rights reserved.`}
</div>
</div>
</footer>
<!-- Body Scripts -->
{globals?.scripts_body && <Fragment set:html={globals.scripts_body} />}
<script>
// Mobile menu toggle
const btn = document.getElementById('mobile-menu-btn');
const menu = document.getElementById('mobile-menu');
btn?.addEventListener('click', () => {
menu?.classList.toggle('hidden');
});
</script>
</body>
</html>