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

32
.env.example Normal file
View File

@@ -0,0 +1,32 @@
# ==========================================
# SPARK PLATFORM ENVIRONMENT VARIABLES
# ==========================================
# Copy this file to .env and fill in the values
# ==========================================
# DIRECTUS CONFIGURATION
# ==========================================
DIRECTUS_SECRET=your-super-secret-key-change-this-in-production
DIRECTUS_ADMIN_EMAIL=admin@spark.local
DIRECTUS_ADMIN_PASSWORD=change-this-password
DIRECTUS_PUBLIC_URL=http://localhost:8055
DIRECTUS_ADMIN_TOKEN=your-admin-token-here
# ==========================================
# DATABASE CONFIGURATION
# ==========================================
POSTGRES_DB=spark
POSTGRES_USER=spark
POSTGRES_PASSWORD=your-secure-password-here
# ==========================================
# FRONTEND CONFIGURATION
# ==========================================
PLATFORM_DOMAIN=localhost
# ==========================================
# PRODUCTION SETTINGS (Optional)
# ==========================================
# NODE_ENV=production
# PUBLIC_DIRECTUS_URL=https://api.yourdomain.com
# PLATFORM_DOMAIN=yourdomain.com

31
.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# Dependencies
node_modules/
# Build output
dist/
.astro/
# Environment files
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Docker volumes
postgres_data/
redis_data/
directus_uploads/
directus_extensions/

234
README.md Normal file
View File

@@ -0,0 +1,234 @@
# Spark Platform
A powerful multi-tenant website platform with SEO automation, content generation, and lead capture.
## 🚀 Features
### Multi-Tenant Website Engine
- Domain-based site routing
- Per-site content isolation
- Global admin + tenant admin access
### Page Builder
- **Hero Block** - Full-width headers with CTAs
- **Rich Text Block** - SEO-optimized prose content
- **Columns Block** - Flexible multi-column layouts
- **Media Block** - Images and videos with captions
- **Steps Block** - Numbered process visualization
- **Quote Block** - Testimonials and blockquotes
- **Gallery Block** - Image grids with hover effects
- **FAQ Block** - Collapsible accordions with schema.org markup
- **Posts Block** - Blog listing with multiple layouts
- **Form Block** - Lead capture with validation
### Agentic SEO Content Engine
- **Campaign Management** - Create SEO campaigns with spintax
- **Headline Generation** - Cartesian product of spintax variations
- **Content Fragments** - Modular 6-pillar content blocks
- **Article Assembly** - Automated 2000+ word article generation
- **Location Targeting** - Generate location-specific content
### US Location Database
- All 50 states + DC
- All 3,143 counties
- Top 50 cities per county by population
### Feature Image Generation
- SVG templates with variable substitution
- Server-side rendering (node-canvas)
- Queue-based batch processing
### Lead Capture
- Customizable forms
- Newsletter subscriptions
- Lead management dashboard
## 📁 Project Structure
```
spark/
├── frontend/ # Astro SSR Frontend
│ ├── src/
│ │ ├── components/
│ │ │ ├── admin/ # Admin dashboard components
│ │ │ ├── blocks/ # Page builder blocks
│ │ │ └── ui/ # ShadCN-style UI components
│ │ ├── layouts/ # Page layouts
│ │ ├── lib/
│ │ │ └── directus/ # Directus SDK client
│ │ ├── pages/
│ │ │ ├── admin/ # Admin dashboard pages
│ │ │ └── api/ # API endpoints
│ │ └── types/ # TypeScript types
│ ├── Dockerfile
│ └── package.json
├── directus/ # Directus Backend
│ ├── scripts/ # Import/automation scripts
│ ├── template/
│ │ └── src/ # Schema definitions
│ │ ├── collections.json
│ │ ├── fields.json
│ │ └── relations.json
│ └── Dockerfile
├── docker-compose.yaml # Full stack orchestration
├── .env.example # Environment template
└── README.md
```
## 🛠️ Quick Start
### Prerequisites
- Docker & Docker Compose
- Node.js 20+ (for local development)
### 1. Clone and Configure
```bash
# Copy environment file
cp .env.example .env
# Edit .env with your settings
nano .env
```
### 2. Start with Docker
```bash
# Build and start all services
docker-compose up -d
# View logs
docker-compose logs -f
```
### 3. Import Schema
```bash
# Enter Directus container
docker-compose exec directus sh
# Install dependencies and import schema
cd /directus
npm install
node scripts/import_template.js
# Load US location data
node scripts/load_locations.js
```
### 4. Access the Platform
- **Frontend**: http://localhost:4321
- **Admin Dashboard**: http://localhost:4321/admin
- **Directus API**: http://localhost:8055
- **Directus Admin**: http://localhost:8055/admin
## 🔧 Development
### Frontend Development
```bash
cd frontend
npm install
npm run dev
```
### Directus Schema Updates
Edit the files in `directus/template/src/` and run:
```bash
node scripts/import_template.js
```
## 📊 SEO Content Engine Usage
### 1. Create a Campaign
In Directus Admin:
1. Go to **SEO Engine → Campaign Masters**
2. Create a new campaign with:
- Name: "Local Dental SEO"
- Headline Spintax: `{Best|Top|Leading} {Dentist|Dental Clinic} in {city}, {state}`
- Location Mode: "City"
### 2. Add Content Fragments
Create fragments for each pillar:
- **intro_hook** (~200 words)
- **pillar_1_keyword** (~300 words)
- **pillar_2_uniqueness** (~300 words)
- **pillar_3_relevance** (~300 words)
- **pillar_4_quality** (~300 words)
- **pillar_5_authority** (~300 words)
- **pillar_6_backlinks** (~300 words)
- **faq_section** (~200 words)
Use spintax and variables:
```html
<p>Looking for the {best|top|leading} {service} in {city}?
{Our team|We} {specialize in|focus on} {providing|delivering}
{exceptional|outstanding} {results|outcomes}.</p>
```
### 3. Generate Headlines
Click "Generate Headlines" to create the headline inventory from spintax permutations.
### 4. Generate Articles
Select a campaign and click "Generate" to create unique SEO articles automatically.
## 🌐 Deployment
### Coolify
1. Create a new Docker Compose service
2. Connect your Git repository
3. Set environment variables
4. Deploy
### Manual Deployment
```bash
# Build images
docker-compose build
# Push to registry
docker tag spark-frontend your-registry/spark-frontend
docker push your-registry/spark-frontend
# Deploy on server
docker-compose -f docker-compose.prod.yaml up -d
```
## 📝 API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/lead` | POST | Submit lead form |
| `/api/campaigns` | GET/POST | Manage SEO campaigns |
| `/api/seo/generate-headlines` | POST | Generate headlines from spintax |
| `/api/seo/generate-article` | POST | Generate articles |
| `/api/seo/articles` | GET | List generated articles |
| `/api/locations/states` | GET | List US states |
| `/api/locations/counties` | GET | List counties by state |
| `/api/locations/cities` | GET | List cities by county |
| `/api/media/templates` | GET/POST | Manage image templates |
## 🔐 Multi-Tenant Access Control
| Role | Access |
|------|--------|
| **Super Admin** | All sites, global settings, location database |
| **Site Admin** | Own site only, content, SEO campaigns |
## 📄 License
MIT License - See LICENSE file for details.
---
Built with ❤️ using Astro, React, Directus, and PostgreSQL.

26
directus/Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
FROM directus/directus:11.1.0
USER root
# Install additional dependencies for image generation
RUN apk add --no-cache \
python3 \
make \
g++ \
cairo-dev \
jpeg-dev \
pango-dev \
giflib-dev \
pixman-dev \
pangomm-dev \
libjpeg-turbo-dev \
freetype-dev
USER node
# Copy extensions and scripts
COPY --chown=node:node ./extensions /directus/extensions
COPY --chown=node:node ./scripts /directus/scripts
COPY --chown=node:node ./template /directus/template
WORKDIR /directus

View File

@@ -0,0 +1,2 @@
# Placeholder for Directus extensions
# Custom extensions go here

14
directus/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "spark-platform-directus",
"version": "1.0.0",
"scripts": {
"import": "node scripts/import_template.js",
"load-locations": "node scripts/load_locations.js",
"generate-headlines": "node scripts/generate_headlines.js",
"assemble-article": "node scripts/assemble_article.js"
},
"dependencies": {
"@directus/sdk": "^17.0.0",
"dotenv": "^16.3.1"
}
}

View File

@@ -0,0 +1,79 @@
/**
* Spark Platform - Directus Schema Import Script
*
* This script imports the collections, fields, and relations from the template
* into a fresh Directus instance.
*
* Usage: node scripts/import_template.js
*/
require('dotenv').config();
const { createDirectus, rest, staticToken, schemaApply, createCollection, createField, createRelation } = require('@directus/sdk');
const collections = require('../template/src/collections.json');
const fields = require('../template/src/fields.json');
const relations = require('../template/src/relations.json');
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'http://localhost:8055';
const DIRECTUS_TOKEN = process.env.DIRECTUS_ADMIN_TOKEN;
if (!DIRECTUS_TOKEN) {
console.error('❌ DIRECTUS_ADMIN_TOKEN is required');
process.exit(1);
}
const directus = createDirectus(DIRECTUS_URL).with(rest()).with(staticToken(DIRECTUS_TOKEN));
async function importSchema() {
console.log('🚀 Starting Spark Platform schema import...\n');
// Create collections
console.log('📦 Creating collections...');
for (const collection of collections) {
try {
await directus.request(createCollection(collection));
console.log(`${collection.collection}`);
} catch (err) {
if (err.message?.includes('already exists')) {
console.log(` ⏭️ ${collection.collection} (exists)`);
} else {
console.log(`${collection.collection}: ${err.message}`);
}
}
}
// Create fields
console.log('\n📝 Creating fields...');
for (const [collectionName, collectionFields] of Object.entries(fields)) {
for (const field of collectionFields) {
try {
await directus.request(createField(collectionName, field));
console.log(`${collectionName}.${field.field}`);
} catch (err) {
if (err.message?.includes('already exists')) {
console.log(` ⏭️ ${collectionName}.${field.field} (exists)`);
} else {
console.log(`${collectionName}.${field.field}: ${err.message}`);
}
}
}
}
// Create relations
console.log('\n🔗 Creating relations...');
for (const relation of relations) {
try {
await directus.request(createRelation(relation));
console.log(`${relation.collection}.${relation.field}${relation.related_collection}`);
} catch (err) {
if (err.message?.includes('already exists')) {
console.log(` ⏭️ ${relation.collection}.${relation.field} (exists)`);
} else {
console.log(`${relation.collection}.${relation.field}: ${err.message}`);
}
}
}
console.log('\n✨ Schema import complete!');
}
importSchema().catch(console.error);

View File

@@ -0,0 +1,179 @@
/**
* Spark Platform - US Location Data Loader
*
* This script loads US states, counties, and top cities into Directus.
*
* Usage: node scripts/load_locations.js
*/
require('dotenv').config();
const { createDirectus, rest, staticToken, createItem, readItems } = require('@directus/sdk');
const fs = require('fs');
const path = require('path');
const DIRECTUS_URL = process.env.DIRECTUS_URL || 'http://localhost:8055';
const DIRECTUS_TOKEN = process.env.DIRECTUS_ADMIN_TOKEN;
if (!DIRECTUS_TOKEN) {
console.error('❌ DIRECTUS_ADMIN_TOKEN is required');
process.exit(1);
}
const directus = createDirectus(DIRECTUS_URL).with(rest()).with(staticToken(DIRECTUS_TOKEN));
// US States data
const US_STATES = [
{ name: 'Alabama', code: 'AL' },
{ name: 'Alaska', code: 'AK' },
{ name: 'Arizona', code: 'AZ' },
{ name: 'Arkansas', code: 'AR' },
{ name: 'California', code: 'CA' },
{ name: 'Colorado', code: 'CO' },
{ name: 'Connecticut', code: 'CT' },
{ name: 'Delaware', code: 'DE' },
{ name: 'Florida', code: 'FL' },
{ name: 'Georgia', code: 'GA' },
{ name: 'Hawaii', code: 'HI' },
{ name: 'Idaho', code: 'ID' },
{ name: 'Illinois', code: 'IL' },
{ name: 'Indiana', code: 'IN' },
{ name: 'Iowa', code: 'IA' },
{ name: 'Kansas', code: 'KS' },
{ name: 'Kentucky', code: 'KY' },
{ name: 'Louisiana', code: 'LA' },
{ name: 'Maine', code: 'ME' },
{ name: 'Maryland', code: 'MD' },
{ name: 'Massachusetts', code: 'MA' },
{ name: 'Michigan', code: 'MI' },
{ name: 'Minnesota', code: 'MN' },
{ name: 'Mississippi', code: 'MS' },
{ name: 'Missouri', code: 'MO' },
{ name: 'Montana', code: 'MT' },
{ name: 'Nebraska', code: 'NE' },
{ name: 'Nevada', code: 'NV' },
{ name: 'New Hampshire', code: 'NH' },
{ name: 'New Jersey', code: 'NJ' },
{ name: 'New Mexico', code: 'NM' },
{ name: 'New York', code: 'NY' },
{ name: 'North Carolina', code: 'NC' },
{ name: 'North Dakota', code: 'ND' },
{ name: 'Ohio', code: 'OH' },
{ name: 'Oklahoma', code: 'OK' },
{ name: 'Oregon', code: 'OR' },
{ name: 'Pennsylvania', code: 'PA' },
{ name: 'Rhode Island', code: 'RI' },
{ name: 'South Carolina', code: 'SC' },
{ name: 'South Dakota', code: 'SD' },
{ name: 'Tennessee', code: 'TN' },
{ name: 'Texas', code: 'TX' },
{ name: 'Utah', code: 'UT' },
{ name: 'Vermont', code: 'VT' },
{ name: 'Virginia', code: 'VA' },
{ name: 'Washington', code: 'WA' },
{ name: 'West Virginia', code: 'WV' },
{ name: 'Wisconsin', code: 'WI' },
{ name: 'Wyoming', code: 'WY' },
{ name: 'District of Columbia', code: 'DC' }
];
async function loadLocations() {
console.log('🚀 Loading US location data...\n');
// Check if data already loaded
const existingStates = await directus.request(
readItems('locations_states', { limit: 1 })
);
if (existingStates.length > 0) {
console.log('📊 Location data already loaded. Skipping...');
return;
}
// Load states
console.log('🗺️ Loading states...');
const stateMap = new Map();
for (const state of US_STATES) {
try {
const result = await directus.request(
createItem('locations_states', {
name: state.name,
code: state.code,
country_code: 'US'
})
);
stateMap.set(state.code, result.id);
console.log(`${state.name} (${state.code})`);
} catch (err) {
console.log(`${state.name}: ${err.message}`);
}
}
// Check if we have the full locations.json file
const locationsFile = path.join(__dirname, '../template/src/locations.json');
if (fs.existsSync(locationsFile)) {
console.log('\n📦 Loading counties and cities from locations.json...');
const locations = JSON.parse(fs.readFileSync(locationsFile, 'utf8'));
// Load counties
const countyMap = new Map();
console.log(` Loading ${locations.counties?.length || 0} counties...`);
for (const county of (locations.counties || [])) {
const stateId = stateMap.get(county.state_code);
if (!stateId) continue;
try {
const result = await directus.request(
createItem('locations_counties', {
name: county.name,
state: stateId,
fips_code: county.fips_code,
population: county.population
})
);
countyMap.set(county.fips_code, result.id);
} catch (err) {
// Silently continue on duplicate
}
}
console.log(` ✅ Counties loaded`);
// Load cities
console.log(` Loading cities (top 50 per county)...`);
let cityCount = 0;
for (const city of (locations.cities || [])) {
const countyId = countyMap.get(city.county_fips);
const stateId = stateMap.get(city.state_code);
if (!countyId || !stateId) continue;
try {
await directus.request(
createItem('locations_cities', {
name: city.name,
county: countyId,
state: stateId,
lat: city.lat,
lng: city.lng,
population: city.population,
postal_code: city.postal_code,
ranking: city.ranking
})
);
cityCount++;
} catch (err) {
// Silently continue on duplicate
}
}
console.log(`${cityCount} cities loaded`);
} else {
console.log('\n⚠ Full locations.json not found. Only states loaded.');
console.log(' Download full US location data from GeoNames and run this script again.');
}
console.log('\n✨ Location data import complete!');
}
loadLocations().catch(console.error);

View File

@@ -0,0 +1,243 @@
[
{
"collection": "sites",
"meta": {
"icon": "domain",
"note": "Multi-tenant sites/domains",
"singleton": false
}
},
{
"collection": "pages",
"meta": {
"icon": "article",
"note": "Site pages with block builder",
"singleton": false
}
},
{
"collection": "posts",
"meta": {
"icon": "edit_note",
"note": "Blog posts and articles",
"singleton": false
}
},
{
"collection": "globals",
"meta": {
"icon": "settings",
"note": "Site-wide settings and branding",
"singleton": false
}
},
{
"collection": "navigation",
"meta": {
"icon": "menu",
"note": "Site navigation menus",
"singleton": false
}
},
{
"collection": "authors",
"meta": {
"icon": "person",
"note": "Content authors",
"singleton": false
}
},
{
"collection": "leads",
"meta": {
"icon": "contact_mail",
"note": "Captured leads from forms",
"singleton": false
}
},
{
"collection": "newsletter_subscribers",
"meta": {
"icon": "email",
"note": "Newsletter subscribers",
"singleton": false
}
},
{
"collection": "forms",
"meta": {
"icon": "dynamic_form",
"note": "Custom form definitions",
"singleton": false
}
},
{
"collection": "form_submissions",
"meta": {
"icon": "inbox",
"note": "Form submission records",
"singleton": false
}
},
{
"collection": "campaign_masters",
"meta": {
"icon": "campaign",
"note": "SEO content campaigns",
"singleton": false,
"group": "seo_engine"
}
},
{
"collection": "headline_inventory",
"meta": {
"icon": "title",
"note": "Generated headlines from spintax",
"singleton": false,
"group": "seo_engine"
}
},
{
"collection": "content_fragments",
"meta": {
"icon": "extension",
"note": "Modular content blocks for article assembly",
"singleton": false,
"group": "seo_engine"
}
},
{
"collection": "generated_articles",
"meta": {
"icon": "auto_awesome",
"note": "AI-assembled SEO articles",
"singleton": false,
"group": "seo_engine"
}
},
{
"collection": "image_templates",
"meta": {
"icon": "image",
"note": "SVG templates for feature images",
"singleton": false
}
},
{
"collection": "locations_states",
"meta": {
"icon": "map",
"note": "US States",
"singleton": false,
"group": "locations"
}
},
{
"collection": "locations_counties",
"meta": {
"icon": "location_city",
"note": "US Counties",
"singleton": false,
"group": "locations"
}
},
{
"collection": "locations_cities",
"meta": {
"icon": "place",
"note": "US Cities (top 50 per county)",
"singleton": false,
"group": "locations"
}
},
{
"collection": "block_hero",
"meta": {
"icon": "view_carousel",
"note": "Hero section block",
"singleton": false,
"group": "blocks"
}
},
{
"collection": "block_richtext",
"meta": {
"icon": "subject",
"note": "Rich text content block",
"singleton": false,
"group": "blocks"
}
},
{
"collection": "block_columns",
"meta": {
"icon": "view_column",
"note": "Multi-column layout block",
"singleton": false,
"group": "blocks"
}
},
{
"collection": "block_media",
"meta": {
"icon": "perm_media",
"note": "Image/video block",
"singleton": false,
"group": "blocks"
}
},
{
"collection": "block_steps",
"meta": {
"icon": "format_list_numbered",
"note": "Numbered steps/process block",
"singleton": false,
"group": "blocks"
}
},
{
"collection": "block_quote",
"meta": {
"icon": "format_quote",
"note": "Quote/testimonial block",
"singleton": false,
"group": "blocks"
}
},
{
"collection": "block_gallery",
"meta": {
"icon": "photo_library",
"note": "Image gallery block",
"singleton": false,
"group": "blocks"
}
},
{
"collection": "block_faq",
"meta": {
"icon": "help",
"note": "FAQ accordion block",
"singleton": false,
"group": "blocks"
}
},
{
"collection": "block_form",
"meta": {
"icon": "contact_page",
"note": "Lead capture form block",
"singleton": false,
"group": "blocks"
}
},
{
"collection": "block_posts",
"meta": {
"icon": "library_books",
"note": "Posts listing block",
"singleton": false,
"group": "blocks"
}
}
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,189 @@
[
{
"collection": "pages",
"field": "site",
"related_collection": "sites",
"meta": {
"many_collection": "pages",
"many_field": "site",
"one_collection": "sites",
"one_field": null
}
},
{
"collection": "posts",
"field": "site",
"related_collection": "sites",
"meta": {
"many_collection": "posts",
"many_field": "site",
"one_collection": "sites",
"one_field": null
}
},
{
"collection": "posts",
"field": "author",
"related_collection": "authors",
"meta": {
"many_collection": "posts",
"many_field": "author",
"one_collection": "authors",
"one_field": null
}
},
{
"collection": "globals",
"field": "site",
"related_collection": "sites",
"meta": {
"many_collection": "globals",
"many_field": "site",
"one_collection": "sites",
"one_field": null
}
},
{
"collection": "navigation",
"field": "site",
"related_collection": "sites",
"meta": {
"many_collection": "navigation",
"many_field": "site",
"one_collection": "sites",
"one_field": null
}
},
{
"collection": "navigation",
"field": "parent",
"related_collection": "navigation",
"meta": {
"many_collection": "navigation",
"many_field": "parent",
"one_collection": "navigation",
"one_field": null
}
},
{
"collection": "leads",
"field": "site",
"related_collection": "sites",
"meta": {
"many_collection": "leads",
"many_field": "site",
"one_collection": "sites",
"one_field": null
}
},
{
"collection": "campaign_masters",
"field": "site",
"related_collection": "sites",
"meta": {
"many_collection": "campaign_masters",
"many_field": "site",
"one_collection": "sites",
"one_field": null
}
},
{
"collection": "headline_inventory",
"field": "campaign",
"related_collection": "campaign_masters",
"meta": {
"many_collection": "headline_inventory",
"many_field": "campaign",
"one_collection": "campaign_masters",
"one_field": null
}
},
{
"collection": "headline_inventory",
"field": "used_on_article",
"related_collection": "generated_articles",
"meta": {
"many_collection": "headline_inventory",
"many_field": "used_on_article",
"one_collection": "generated_articles",
"one_field": null
}
},
{
"collection": "content_fragments",
"field": "campaign",
"related_collection": "campaign_masters",
"meta": {
"many_collection": "content_fragments",
"many_field": "campaign",
"one_collection": "campaign_masters",
"one_field": null
}
},
{
"collection": "generated_articles",
"field": "site",
"related_collection": "sites",
"meta": {
"many_collection": "generated_articles",
"many_field": "site",
"one_collection": "sites",
"one_field": null
}
},
{
"collection": "generated_articles",
"field": "campaign",
"related_collection": "campaign_masters",
"meta": {
"many_collection": "generated_articles",
"many_field": "campaign",
"one_collection": "campaign_masters",
"one_field": null
}
},
{
"collection": "locations_counties",
"field": "state",
"related_collection": "locations_states",
"meta": {
"many_collection": "locations_counties",
"many_field": "state",
"one_collection": "locations_states",
"one_field": null
}
},
{
"collection": "locations_cities",
"field": "county",
"related_collection": "locations_counties",
"meta": {
"many_collection": "locations_cities",
"many_field": "county",
"one_collection": "locations_counties",
"one_field": null
}
},
{
"collection": "locations_cities",
"field": "state",
"related_collection": "locations_states",
"meta": {
"many_collection": "locations_cities",
"many_field": "state",
"one_collection": "locations_states",
"one_field": null
}
},
{
"collection": "image_templates",
"field": "site",
"related_collection": "sites",
"meta": {
"many_collection": "image_templates",
"many_field": "site",
"one_collection": "sites",
"one_field": null
}
}
]

115
docker-compose.yaml Normal file
View File

@@ -0,0 +1,115 @@
version: '3.8'
services:
# ==========================================
# DIRECTUS (Headless CMS)
# ==========================================
directus:
build:
context: ./directus
dockerfile: Dockerfile
container_name: spark-directus
restart: unless-stopped
ports:
- "8055:8055"
environment:
SECRET: ${DIRECTUS_SECRET:-super-secret-key-change-in-production}
ADMIN_EMAIL: ${DIRECTUS_ADMIN_EMAIL:-admin@spark.local}
ADMIN_PASSWORD: ${DIRECTUS_ADMIN_PASSWORD:-admin}
DB_CLIENT: pg
DB_HOST: postgres
DB_PORT: 5432
DB_DATABASE: ${POSTGRES_DB:-spark}
DB_USER: ${POSTGRES_USER:-spark}
DB_PASSWORD: ${POSTGRES_PASSWORD:-spark}
CACHE_ENABLED: "true"
CACHE_STORE: redis
REDIS_HOST: redis
REDIS_PORT: 6379
CORS_ENABLED: "true"
CORS_ORIGIN: "*"
PUBLIC_URL: ${DIRECTUS_PUBLIC_URL:-http://localhost:8055}
STORAGE_LOCATIONS: local
STORAGE_LOCAL_ROOT: /directus/uploads
volumes:
- directus_uploads:/directus/uploads
- directus_extensions:/directus/extensions
depends_on:
- postgres
- redis
networks:
- spark-network
# ==========================================
# FRONTEND (Astro SSR)
# ==========================================
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: spark-frontend
restart: unless-stopped
ports:
- "4321:4321"
environment:
PUBLIC_DIRECTUS_URL: ${DIRECTUS_PUBLIC_URL:-http://directus:8055}
DIRECTUS_ADMIN_TOKEN: ${DIRECTUS_ADMIN_TOKEN:-}
PUBLIC_PLATFORM_DOMAIN: ${PLATFORM_DOMAIN:-localhost}
depends_on:
- directus
networks:
- spark-network
# ==========================================
# POSTGRESQL DATABASE
# ==========================================
postgres:
image: postgres:16-alpine
container_name: spark-postgres
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-spark}
POSTGRES_USER: ${POSTGRES_USER:-spark}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-spark}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- spark-network
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-spark}" ]
interval: 10s
timeout: 5s
retries: 5
# ==========================================
# REDIS CACHE
# ==========================================
redis:
image: redis:7-alpine
container_name: spark-redis
restart: unless-stopped
volumes:
- redis_data:/data
networks:
- spark-network
healthcheck:
test: [ "CMD", "redis-cli", "ping" ]
interval: 10s
timeout: 5s
retries: 5
# ==========================================
# VOLUMES
# ==========================================
volumes:
postgres_data:
redis_data:
directus_uploads:
directus_extensions:
# ==========================================
# NETWORKS
# ==========================================
networks:
spark-network:
driver: bridge

28
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
# ========= BASE =========
FROM node:20-alpine AS base
WORKDIR /app
# ========= DEPENDENCIES =========
FROM base AS deps
COPY package.json package-lock.json* ./
RUN npm ci
# ========= BUILD =========
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# ========= RUNNER =========
FROM base AS runner
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=4321
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json .
EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]

27
frontend/astro.config.ts Normal file
View File

@@ -0,0 +1,27 @@
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
import react from '@astrojs/react';
import node from '@astrojs/node';
// Spark Platform - Multi-Tenant SSR Configuration
export default defineConfig({
output: 'server',
adapter: node({
mode: 'standalone'
}),
integrations: [
react(),
tailwind({
applyBaseStyles: true
})
],
server: {
port: Number(process.env.PORT) || 4321,
host: true
},
vite: {
optimizeDeps: {
exclude: ['@directus/sdk']
}
}
});

44
frontend/package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "spark-platform-frontend",
"type": "module",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"lint": "eslint src --ext .ts,.tsx,.astro"
},
"dependencies": {
"@astrojs/node": "^8.2.6",
"@astrojs/react": "^3.2.0",
"@astrojs/tailwind": "^5.1.0",
"@directus/sdk": "^17.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",
"astro": "^4.7.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.346.0",
"nanoid": "^5.0.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwind-merge": "^2.2.1",
"tailwindcss": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
},
"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",
"sharp": "^0.33.3",
"typescript": "^5.4.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="32" height="32">
<defs>
<linearGradient id="spark-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#3b82f6"/>
<stop offset="100%" style="stop-color:#8b5cf6"/>
</linearGradient>
</defs>
<circle cx="50" cy="50" r="45" fill="url(#spark-gradient)"/>
<path d="M55 20L35 50h15L40 80l30-35H52l8-25z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 446 B

View File

@@ -0,0 +1,203 @@
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
interface Article {
id: string;
headline: string;
meta_title: string;
word_count: number;
is_published: boolean;
location_city?: string;
location_state?: string;
date_created: string;
}
interface Campaign {
id: string;
name: string;
}
export default function ArticleGenerator() {
const [articles, setArticles] = useState<Article[]>([]);
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [selectedCampaign, setSelectedCampaign] = useState('');
const [batchSize, setBatchSize] = useState(1);
useEffect(() => {
Promise.all([fetchArticles(), fetchCampaigns()]).finally(() => setLoading(false));
}, []);
async function fetchArticles() {
try {
const res = await fetch('/api/seo/articles');
const data = await res.json();
setArticles(data.articles || []);
} catch (err) {
console.error('Error fetching articles:', err);
}
}
async function fetchCampaigns() {
try {
const res = await fetch('/api/campaigns');
const data = await res.json();
setCampaigns(data.campaigns || []);
} catch (err) {
console.error('Error fetching campaigns:', err);
}
}
async function generateArticle() {
if (!selectedCampaign) {
alert('Please select a campaign first');
return;
}
setGenerating(true);
try {
const res = await fetch('/api/seo/generate-article', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
campaign_id: selectedCampaign,
batch_size: batchSize
})
});
if (res.ok) {
alert(`${batchSize} article(s) generation started!`);
fetchArticles();
}
} catch (err) {
console.error('Error generating article:', err);
} finally {
setGenerating(false);
}
}
async function publishArticle(articleId: string) {
try {
await fetch(`/api/seo/articles/${articleId}/publish`, { method: 'POST' });
fetchArticles();
} catch (err) {
console.error('Error publishing article:', err);
}
}
if (loading) {
return <Spinner className="py-12" />;
}
return (
<div className="space-y-6">
{/* Generator Controls */}
<Card>
<CardHeader>
<CardTitle>Generate New Articles</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-4 items-end">
<div className="flex-1 min-w-48">
<label className="block text-sm font-medium mb-2 text-gray-400">Campaign</label>
<select
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white"
value={selectedCampaign}
onChange={(e) => setSelectedCampaign(e.target.value)}
>
<option value="">Select a campaign...</option>
{campaigns.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div className="w-32">
<label className="block text-sm font-medium mb-2 text-gray-400">Batch Size</label>
<select
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white"
value={batchSize}
onChange={(e) => setBatchSize(Number(e.target.value))}
>
<option value="1">1 Article</option>
<option value="5">5 Articles</option>
<option value="10">10 Articles</option>
<option value="25">25 Articles</option>
<option value="50">50 Articles</option>
</select>
</div>
<Button
onClick={generateArticle}
disabled={generating || !selectedCampaign}
className="min-w-32"
>
{generating ? 'Generating...' : 'Generate'}
</Button>
</div>
</CardContent>
</Card>
{/* Articles List */}
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold text-white">Generated Articles ({articles.length})</h2>
<Button variant="outline" onClick={fetchArticles}>Refresh</Button>
</div>
<div className="grid gap-4">
{articles.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-gray-400">
No articles generated yet. Select a campaign and click Generate.
</CardContent>
</Card>
) : (
articles.map((article) => (
<Card key={article.id}>
<CardContent className="p-6">
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold text-white line-clamp-1">
{article.headline}
</h3>
<Badge variant={article.is_published ? 'success' : 'secondary'}>
{article.is_published ? 'Published' : 'Draft'}
</Badge>
</div>
<p className="text-gray-400 text-sm mb-2">{article.meta_title}</p>
<div className="flex gap-4 text-sm text-gray-500">
<span>{article.word_count} words</span>
{article.location_city && (
<span>{article.location_city}, {article.location_state}</span>
)}
<span>{new Date(article.date_created).toLocaleDateString()}</span>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm">Preview</Button>
{!article.is_published && (
<Button
size="sm"
onClick={() => publishArticle(article.id)}
>
Publish
</Button>
)}
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,311 @@
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
interface Campaign {
id: string;
name: string;
headline_spintax_root: string;
location_mode: string;
status: string;
date_created: string;
}
interface GenerationResult {
metadata: {
slotCount: number;
spintaxCombinations: number;
locationCount: number;
totalPossible: number;
wasTruncated: boolean;
};
results: {
processed: number;
inserted: number;
skipped: number;
alreadyExisted: number;
};
}
export default function CampaignManager() {
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState<string | null>(null);
const [lastResult, setLastResult] = useState<GenerationResult | null>(null);
const [showForm, setShowForm] = useState(false);
const [formData, setFormData] = useState({
name: '',
headline_spintax_root: '',
location_mode: 'none',
niche_variables: '{}'
});
useEffect(() => {
fetchCampaigns();
}, []);
async function fetchCampaigns() {
try {
const res = await fetch('/api/campaigns');
const data = await res.json();
setCampaigns(data.campaigns || []);
} catch (err) {
console.error('Error fetching campaigns:', err);
} finally {
setLoading(false);
}
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
try {
await fetch('/api/campaigns', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
setShowForm(false);
setFormData({
name: '',
headline_spintax_root: '',
location_mode: 'none',
niche_variables: '{}'
});
fetchCampaigns();
} catch (err) {
console.error('Error creating campaign:', err);
}
}
async function generateHeadlines(campaignId: string) {
setGenerating(campaignId);
setLastResult(null);
try {
const res = await fetch('/api/seo/generate-headlines', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
campaign_id: campaignId,
max_headlines: 10000
})
});
const data = await res.json();
if (data.success) {
setLastResult(data as GenerationResult);
} else {
alert('Error: ' + (data.error || 'Unknown error'));
}
} catch (err) {
console.error('Error generating headlines:', err);
alert('Failed to generate headlines');
} finally {
setGenerating(null);
}
}
if (loading) {
return <Spinner className="py-12" />;
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<p className="text-gray-400">
Manage your SEO campaigns with Cartesian Permutation headline generation.
</p>
<Button onClick={() => setShowForm(true)}>
+ New Campaign
</Button>
</div>
{/* Generation Result Modal */}
{lastResult && (
<Card className="border-green-500/50 bg-green-500/10">
<CardContent className="p-6">
<div className="flex justify-between items-start">
<div>
<h3 className="text-lg font-semibold text-green-400 mb-4">
Headlines Generated Successfully
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-gray-400">Spintax Slots:</span>
<p className="text-white font-mono">{lastResult.metadata.slotCount}</p>
</div>
<div>
<span className="text-gray-400">Spintax Combinations:</span>
<p className="text-white font-mono">{lastResult.metadata.spintaxCombinations.toLocaleString()}</p>
</div>
<div>
<span className="text-gray-400">Locations:</span>
<p className="text-white font-mono">{lastResult.metadata.locationCount.toLocaleString()}</p>
</div>
<div>
<span className="text-gray-400">Total Possible (n×k):</span>
<p className="text-white font-mono">{lastResult.metadata.totalPossible.toLocaleString()}</p>
</div>
</div>
<div className="mt-4 flex gap-6 text-sm">
<span className="text-green-400">
Inserted: {lastResult.results.inserted.toLocaleString()}
</span>
<span className="text-yellow-400">
Skipped (duplicates): {lastResult.results.skipped.toLocaleString()}
</span>
<span className="text-gray-400">
Already existed: {lastResult.results.alreadyExisted.toLocaleString()}
</span>
</div>
{lastResult.metadata.wasTruncated && (
<p className="mt-3 text-yellow-400 text-sm">
Results truncated to 10,000 headlines (safety limit)
</p>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setLastResult(null)}
>
Dismiss
</Button>
</div>
</CardContent>
</Card>
)}
{showForm && (
<Card>
<CardHeader>
<CardTitle>Create New Campaign</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Campaign Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g., Local Dental SEO"
required
/>
<div>
<Textarea
label="Headline Spintax"
value={formData.headline_spintax_root}
onChange={(e) => setFormData({ ...formData, headline_spintax_root: e.target.value })}
placeholder="{Best|Top|Leading} {Dentist|Dental Clinic} in {city}"
rows={4}
required
/>
<p className="text-xs text-gray-500 mt-1">
Use {'{option1|option2}'} for variations. Formula: n × n × ... × nₖ × locations
</p>
</div>
<div>
<label className="block text-sm font-medium mb-2">Location Mode</label>
<select
className="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-md text-white"
value={formData.location_mode}
onChange={(e) => setFormData({ ...formData, location_mode: e.target.value })}
>
<option value="none">No Location</option>
<option value="state">By State (51 variations)</option>
<option value="county">By County (3,143 variations)</option>
<option value="city">By City (top 1,000 variations)</option>
</select>
</div>
<Textarea
label="Niche Variables (JSON)"
value={formData.niche_variables}
onChange={(e) => setFormData({ ...formData, niche_variables: e.target.value })}
placeholder='{"target": "homeowners", "service": "dental"}'
rows={3}
/>
<div className="flex gap-3">
<Button type="submit">Create Campaign</Button>
<Button type="button" variant="outline" onClick={() => setShowForm(false)}>
Cancel
</Button>
</div>
</form>
</CardContent>
</Card>
)}
<div className="grid gap-4">
{campaigns.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-gray-400">
No campaigns yet. Create your first SEO campaign to get started.
</CardContent>
</Card>
) : (
campaigns.map((campaign) => (
<Card key={campaign.id}>
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold text-white">{campaign.name}</h3>
<Badge variant={campaign.status === 'active' ? 'success' : 'secondary'}>
{campaign.status}
</Badge>
{campaign.location_mode !== 'none' && (
<Badge variant="outline">{campaign.location_mode}</Badge>
)}
</div>
<p className="text-gray-400 text-sm font-mono bg-gray-700/50 p-2 rounded mt-2">
{campaign.headline_spintax_root.substring(0, 100)}
{campaign.headline_spintax_root.length > 100 ? '...' : ''}
</p>
<p className="text-gray-500 text-sm mt-2">
Created: {new Date(campaign.date_created).toLocaleDateString()}
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => generateHeadlines(campaign.id)}
disabled={generating === campaign.id}
>
{generating === campaign.id ? (
<>
<Spinner size="sm" className="mr-2" />
Generating...
</>
) : (
'Generate Headlines'
)}
</Button>
<Button variant="secondary" size="sm">
Edit
</Button>
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,232 @@
import React, { useState, useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
interface Template {
id: string;
name: string;
svg_source: string;
is_default: boolean;
preview?: string;
}
const DEFAULT_SVG = `<svg width="1200" height="630" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#1e3a8a"/>
<stop offset="100%" style="stop-color:#7c3aed"/>
</linearGradient>
</defs>
<rect width="100%" height="100%" fill="url(#bg)"/>
<text x="60" y="280" font-family="Arial, sans-serif" font-size="64" font-weight="bold" fill="white">{title}</text>
<text x="60" y="360" font-family="Arial, sans-serif" font-size="28" fill="rgba(255,255,255,0.8)">{subtitle}</text>
<text x="60" y="580" font-family="Arial, sans-serif" font-size="20" fill="rgba(255,255,255,0.6)">{site_name} • {city}, {state}</text>
</svg>`;
export default function ImageTemplateEditor() {
const [templates, setTemplates] = useState<Template[]>([]);
const [loading, setLoading] = useState(true);
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
const [editedSvg, setEditedSvg] = useState('');
const [previewData, setPreviewData] = useState({
title: 'Amazing Article Title Here',
subtitle: 'Your compelling subtitle goes here',
site_name: 'Spark Platform',
city: 'Austin',
state: 'TX'
});
const previewRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetchTemplates();
}, []);
async function fetchTemplates() {
try {
const res = await fetch('/api/media/templates');
const data = await res.json();
setTemplates(data.templates || []);
// Select first template or use default
if (data.templates?.length > 0) {
selectTemplate(data.templates[0]);
} else {
setEditedSvg(DEFAULT_SVG);
}
} catch (err) {
console.error('Error fetching templates:', err);
setEditedSvg(DEFAULT_SVG);
} finally {
setLoading(false);
}
}
function selectTemplate(template: Template) {
setSelectedTemplate(template);
setEditedSvg(template.svg_source);
}
function renderPreview() {
let svg = editedSvg;
Object.entries(previewData).forEach(([key, value]) => {
svg = svg.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
});
return svg;
}
async function saveTemplate() {
if (!selectedTemplate) return;
try {
await fetch(`/api/media/templates/${selectedTemplate.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ svg_source: editedSvg })
});
alert('Template saved!');
fetchTemplates();
} catch (err) {
console.error('Error saving template:', err);
}
}
async function createTemplate() {
const name = prompt('Enter template name:');
if (!name) return;
try {
await fetch('/api/media/templates', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, svg_source: DEFAULT_SVG })
});
fetchTemplates();
} catch (err) {
console.error('Error creating template:', err);
}
}
if (loading) {
return <Spinner className="py-12" />;
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<p className="text-gray-400">
Create and manage SVG templates for article feature images.
</p>
<Button onClick={createTemplate}>+ New Template</Button>
</div>
<div className="grid lg:grid-cols-3 gap-6">
{/* Template List */}
<Card>
<CardHeader>
<CardTitle>Templates</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{templates.map((template) => (
<button
key={template.id}
className={`w-full px-4 py-3 rounded-lg text-left transition-colors ${selectedTemplate?.id === template.id
? 'bg-primary text-white'
: 'bg-gray-700/50 hover:bg-gray-700 text-gray-300'
}`}
onClick={() => selectTemplate(template)}
>
<div className="flex items-center justify-between">
<span className="font-medium">{template.name}</span>
{template.is_default && (
<Badge variant="secondary" className="text-xs">Default</Badge>
)}
</div>
</button>
))}
{templates.length === 0 && (
<p className="text-gray-500 text-center py-4">No templates yet</p>
)}
</div>
</CardContent>
</Card>
{/* SVG Editor */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Editor</span>
<Button size="sm" onClick={saveTemplate} disabled={!selectedTemplate}>
Save Template
</Button>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Preview */}
<div className="bg-gray-900 rounded-lg p-4">
<p className="text-gray-400 text-sm mb-2">Preview (1200x630)</p>
<div
ref={previewRef}
className="w-full aspect-[1200/630] rounded-lg overflow-hidden"
dangerouslySetInnerHTML={{ __html: renderPreview() }}
/>
</div>
{/* Preview Variables */}
<div className="grid grid-cols-2 gap-4">
<Input
label="Title"
value={previewData.title}
onChange={(e) => setPreviewData({ ...previewData, title: e.target.value })}
/>
<Input
label="Subtitle"
value={previewData.subtitle}
onChange={(e) => setPreviewData({ ...previewData, subtitle: e.target.value })}
/>
<Input
label="Site Name"
value={previewData.site_name}
onChange={(e) => setPreviewData({ ...previewData, site_name: e.target.value })}
/>
<div className="flex gap-2">
<Input
label="City"
value={previewData.city}
onChange={(e) => setPreviewData({ ...previewData, city: e.target.value })}
/>
<Input
label="State"
value={previewData.state}
onChange={(e) => setPreviewData({ ...previewData, state: e.target.value })}
className="w-20"
/>
</div>
</div>
{/* SVG Source */}
<div>
<label className="block text-sm font-medium text-gray-400 mb-2">
SVG Source (use {'{variable}'} for dynamic content)
</label>
<textarea
className="w-full h-64 px-4 py-3 bg-gray-900 border border-gray-700 rounded-lg font-mono text-sm text-gray-300 focus:ring-2 focus:ring-primary focus:border-transparent"
value={editedSvg}
onChange={(e) => setEditedSvg(e.target.value)}
/>
</div>
<div className="text-sm text-gray-500">
<strong>Available variables:</strong> {'{title}'}, {'{subtitle}'}, {'{site_name}'},
{'{city}'}, {'{state}'}, {'{county}'}, {'{author}'}, {'{date}'}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,250 @@
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
interface State {
id: string;
name: string;
code: string;
}
interface County {
id: string;
name: string;
population?: number;
}
interface City {
id: string;
name: string;
population?: number;
postal_code?: string;
}
export default function LocationBrowser() {
const [states, setStates] = useState<State[]>([]);
const [counties, setCounties] = useState<County[]>([]);
const [cities, setCities] = useState<City[]>([]);
const [selectedState, setSelectedState] = useState<State | null>(null);
const [selectedCounty, setSelectedCounty] = useState<County | null>(null);
const [loading, setLoading] = useState(true);
const [loadingCounties, setLoadingCounties] = useState(false);
const [loadingCities, setLoadingCities] = useState(false);
useEffect(() => {
fetchStates();
}, []);
async function fetchStates() {
try {
const res = await fetch('/api/locations/states');
const data = await res.json();
setStates(data.states || []);
} catch (err) {
console.error('Error fetching states:', err);
} finally {
setLoading(false);
}
}
async function fetchCounties(stateId: string) {
setLoadingCounties(true);
setCounties([]);
setCities([]);
setSelectedCounty(null);
try {
const res = await fetch(`/api/locations/counties?state=${stateId}`);
const data = await res.json();
setCounties(data.counties || []);
} catch (err) {
console.error('Error fetching counties:', err);
} finally {
setLoadingCounties(false);
}
}
async function fetchCities(countyId: string) {
setLoadingCities(true);
setCities([]);
try {
const res = await fetch(`/api/locations/cities?county=${countyId}`);
const data = await res.json();
setCities(data.cities || []);
} catch (err) {
console.error('Error fetching cities:', err);
} finally {
setLoadingCities(false);
}
}
function selectState(state: State) {
setSelectedState(state);
fetchCounties(state.id);
}
function selectCounty(county: County) {
setSelectedCounty(county);
fetchCities(county.id);
}
if (loading) {
return <Spinner className="py-12" />;
}
return (
<div className="space-y-6">
<div className="flex items-center gap-2 text-sm text-gray-400">
<button
className="hover:text-white transition-colors"
onClick={() => {
setSelectedState(null);
setSelectedCounty(null);
setCounties([]);
setCities([]);
}}
>
All States
</button>
{selectedState && (
<>
<span>/</span>
<button
className="hover:text-white transition-colors"
onClick={() => {
setSelectedCounty(null);
setCities([]);
}}
>
{selectedState.name}
</button>
</>
)}
{selectedCounty && (
<>
<span>/</span>
<span className="text-white">{selectedCounty.name}</span>
</>
)}
</div>
<div className="grid lg:grid-cols-3 gap-6">
{/* States Column */}
<Card className={selectedState ? 'opacity-60' : ''}>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>States</span>
<Badge variant="outline">{states.length}</Badge>
</CardTitle>
</CardHeader>
<CardContent className="max-h-[500px] overflow-y-auto">
<div className="space-y-1">
{states.map((state) => (
<button
key={state.id}
className={`w-full px-3 py-2 rounded-lg text-left transition-colors ${selectedState?.id === state.id
? 'bg-primary text-white'
: 'hover:bg-gray-700 text-gray-300'
}`}
onClick={() => selectState(state)}
>
<div className="flex items-center justify-between">
<span>{state.name}</span>
<span className="text-sm opacity-60">{state.code}</span>
</div>
</button>
))}
</div>
</CardContent>
</Card>
{/* Counties Column */}
<Card className={!selectedState ? 'opacity-40' : selectedCounty ? 'opacity-60' : ''}>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Counties</span>
{selectedState && <Badge variant="outline">{counties.length}</Badge>}
</CardTitle>
</CardHeader>
<CardContent className="max-h-[500px] overflow-y-auto">
{loadingCounties ? (
<Spinner />
) : !selectedState ? (
<p className="text-gray-500 text-center py-8">Select a state first</p>
) : counties.length === 0 ? (
<p className="text-gray-500 text-center py-8">No counties found</p>
) : (
<div className="space-y-1">
{counties.map((county) => (
<button
key={county.id}
className={`w-full px-3 py-2 rounded-lg text-left transition-colors ${selectedCounty?.id === county.id
? 'bg-primary text-white'
: 'hover:bg-gray-700 text-gray-300'
}`}
onClick={() => selectCounty(county)}
>
<div className="flex items-center justify-between">
<span>{county.name}</span>
{county.population && (
<span className="text-xs opacity-60">
{county.population.toLocaleString()}
</span>
)}
</div>
</button>
))}
</div>
)}
</CardContent>
</Card>
{/* Cities Column */}
<Card className={!selectedCounty ? 'opacity-40' : ''}>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>Cities (Top 50)</span>
{selectedCounty && <Badge variant="outline">{cities.length}</Badge>}
</CardTitle>
</CardHeader>
<CardContent className="max-h-[500px] overflow-y-auto">
{loadingCities ? (
<Spinner />
) : !selectedCounty ? (
<p className="text-gray-500 text-center py-8">Select a county first</p>
) : cities.length === 0 ? (
<p className="text-gray-500 text-center py-8">No cities found</p>
) : (
<div className="space-y-1">
{cities.map((city, index) => (
<div
key={city.id}
className="px-3 py-2 rounded-lg bg-gray-700/30 text-gray-300"
>
<div className="flex items-center justify-between">
<span className="flex items-center gap-2">
<span className="text-xs text-gray-500 w-5">{index + 1}.</span>
{city.name}
</span>
{city.population && (
<span className="text-xs text-gray-500">
Pop: {city.population.toLocaleString()}
</span>
)}
</div>
{city.postal_code && (
<span className="text-xs text-gray-500 ml-7">{city.postal_code}</span>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
---
interface Column {
content: string;
width?: number;
}
interface Props {
title?: string;
columns: Column[];
gap?: 'sm' | 'md' | 'lg';
vertical_align?: 'top' | 'center' | 'bottom';
}
const {
title,
columns = [],
gap = 'md',
vertical_align = 'top'
} = Astro.props;
const gapClasses = {
sm: 'gap-4',
md: 'gap-8',
lg: 'gap-12'
};
const alignClasses = {
top: 'items-start',
center: 'items-center',
bottom: 'items-end'
};
const columnCount = columns.length;
const gridCols = columnCount === 1 ? 'grid-cols-1'
: columnCount === 2 ? 'md:grid-cols-2'
: columnCount === 3 ? 'md:grid-cols-3'
: 'md:grid-cols-4';
---
<section class="max-w-6xl mx-auto px-6 py-12">
{title && (
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-8 text-center">
{title}
</h2>
)}
<div class={`grid grid-cols-1 ${gridCols} ${gapClasses[gap]} ${alignClasses[vertical_align]}`}>
{columns.map((col) => (
<div
class="prose prose-gray dark:prose-invert max-w-none"
style={col.width ? `flex: 0 0 ${col.width}%` : ''}
set:html={col.content}
/>
))}
</div>
</section>

View File

@@ -0,0 +1,72 @@
---
interface FAQ {
question: string;
answer: string;
}
interface Props {
title?: string;
subtitle?: string;
faqs: FAQ[];
}
const { title, subtitle, faqs = [] } = Astro.props;
// Generate structured data for SEO
const structuredData = {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": faqs.map(faq => ({
"@type": "Question",
"name": faq.question,
"acceptedAnswer": {
"@type": "Answer",
"text": faq.answer
}
}))
};
---
<section class="max-w-4xl mx-auto px-6 py-16">
{(title || subtitle) && (
<div class="text-center mb-12">
{title && (
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{title}
</h2>
)}
{subtitle && (
<p class="text-xl text-gray-600 dark:text-gray-400">
{subtitle}
</p>
)}
</div>
)}
<div class="space-y-4">
{faqs.map((faq, index) => (
<details
class="group bg-white dark:bg-gray-800 rounded-xl shadow-md overflow-hidden border border-gray-200 dark:border-gray-700"
>
<summary class="flex items-center justify-between p-6 cursor-pointer list-none font-semibold text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<span class="pr-6">{faq.question}</span>
<svg
class="w-5 h-5 text-gray-500 transition-transform duration-300 group-open:rotate-180 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div class="px-6 pb-6 pt-2 text-gray-600 dark:text-gray-400 leading-relaxed border-t border-gray-100 dark:border-gray-700">
<div set:html={faq.answer} />
</div>
</details>
))}
</div>
<!-- FAQ Schema for SEO -->
<script type="application/ld+json" set:html={JSON.stringify(structuredData)} />
</section>

View File

@@ -0,0 +1,165 @@
---
import type { Form, FormField } from '@/types/schema';
interface Props {
form_id?: string;
title?: string;
subtitle?: string;
fields?: FormField[];
button_text?: string;
success_message?: string;
style?: 'default' | 'card' | 'inline';
}
const {
form_id,
title,
subtitle,
fields = [
{ name: 'name', label: 'Your Name', type: 'text', required: true },
{ name: 'email', label: 'Email Address', type: 'email', required: true },
{ name: 'phone', label: 'Phone Number', type: 'phone', required: false },
{ name: 'message', label: 'Message', type: 'textarea', required: false }
],
button_text = 'Submit',
success_message = 'Thank you! We\'ll be in touch soon.',
style = 'card'
} = Astro.props;
const formId = form_id || `form-${Math.random().toString(36).substr(2, 9)}`;
---
<section class={`max-w-2xl mx-auto px-6 py-12 ${style === 'card' ? '' : ''}`}>
<div class={style === 'card' ? 'bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8' : ''}>
{(title || subtitle) && (
<div class="text-center mb-8">
{title && (
<h2 class="text-2xl md:text-3xl font-bold text-gray-900 dark:text-white mb-2">
{title}
</h2>
)}
{subtitle && (
<p class="text-gray-600 dark:text-gray-400">
{subtitle}
</p>
)}
</div>
)}
<form id={formId} class="space-y-6" data-success-message={success_message}>
{fields.map((field) => (
<div>
<label
for={`${formId}-${field.name}`}
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
{field.label}
{field.required && <span class="text-red-500 ml-1">*</span>}
</label>
{field.type === 'textarea' ? (
<textarea
id={`${formId}-${field.name}`}
name={field.name}
required={field.required}
placeholder={field.placeholder}
rows={4}
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent transition-colors"
/>
) : field.type === 'select' && field.options ? (
<select
id={`${formId}-${field.name}`}
name={field.name}
required={field.required}
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent transition-colors"
>
<option value="">Select an option</option>
{field.options.map((option) => (
<option value={option}>{option}</option>
))}
</select>
) : field.type === 'checkbox' ? (
<div class="flex items-center">
<input
type="checkbox"
id={`${formId}-${field.name}`}
name={field.name}
required={field.required}
class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary"
/>
<label for={`${formId}-${field.name}`} class="ml-2 text-sm text-gray-600 dark:text-gray-400">
{field.placeholder || field.label}
</label>
</div>
) : (
<input
type={field.type}
id={`${formId}-${field.name}`}
name={field.name}
required={field.required}
placeholder={field.placeholder}
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary focus:border-transparent transition-colors"
/>
)}
</div>
))}
<button
type="submit"
class="w-full px-6 py-4 bg-gradient-to-r from-primary to-purple-600 text-white rounded-lg font-semibold text-lg shadow-lg hover:shadow-xl hover:scale-[1.02] transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span class="submit-text">{button_text}</span>
<span class="loading-text hidden">Submitting...</span>
</button>
</form>
<div id={`${formId}-success`} class="hidden text-center py-8">
<div class="w-16 h-16 bg-green-100 dark:bg-green-900 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<p class="text-lg font-semibold text-gray-900 dark:text-white">{success_message}</p>
</div>
</div>
</section>
<script>
document.querySelectorAll('form[id^="form-"]').forEach(form => {
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formEl = e.target as HTMLFormElement;
const formId = formEl.id;
const submitBtn = formEl.querySelector('button[type="submit"]') as HTMLButtonElement;
const submitText = submitBtn.querySelector('.submit-text');
const loadingText = submitBtn.querySelector('.loading-text');
// Show loading state
submitBtn.disabled = true;
submitText?.classList.add('hidden');
loadingText?.classList.remove('hidden');
try {
const formData = new FormData(formEl);
const data = Object.fromEntries(formData.entries());
const response = await fetch('/api/lead', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
formEl.classList.add('hidden');
document.getElementById(`${formId}-success`)?.classList.remove('hidden');
}
} catch (err) {
console.error('Form submission error:', err);
} finally {
submitBtn.disabled = false;
submitText?.classList.remove('hidden');
loadingText?.classList.add('hidden');
}
});
});
</script>

View File

@@ -0,0 +1,62 @@
---
interface GalleryImage {
url: string;
alt?: string;
caption?: string;
}
interface Props {
title?: string;
images: GalleryImage[];
columns?: 2 | 3 | 4;
gap?: 'sm' | 'md' | 'lg';
style?: 'grid' | 'masonry';
}
const {
title,
images = [],
columns = 3,
gap = 'md',
style = 'grid'
} = Astro.props;
const colClasses = {
2: 'md:grid-cols-2',
3: 'md:grid-cols-3',
4: 'md:grid-cols-4'
};
const gapClasses = {
sm: 'gap-2',
md: 'gap-4',
lg: 'gap-6'
};
---
<section class="max-w-6xl mx-auto px-6 py-12">
{title && (
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-8 text-center">
{title}
</h2>
)}
<div class={`grid grid-cols-2 ${colClasses[columns]} ${gapClasses[gap]}`}>
{images.map((img, index) => (
<figure class="group relative overflow-hidden rounded-xl">
<img
src={img.url}
alt={img.alt || img.caption || `Gallery image ${index + 1}`}
loading="lazy"
class="w-full h-64 object-cover transition-transform duration-500 group-hover:scale-110"
/>
{img.caption && (
<figcaption class="absolute inset-0 bg-black/60 flex items-end p-4 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
<span class="text-white text-sm">{img.caption}</span>
</figcaption>
)}
</figure>
))}
</div>
</section>

View File

@@ -0,0 +1,78 @@
---
interface Props {
title: string;
subtitle?: string;
background_image?: string;
background_gradient?: string;
cta_text?: string;
cta_url?: string;
cta_secondary_text?: string;
cta_secondary_url?: string;
alignment?: 'left' | 'center' | 'right';
}
const {
title,
subtitle,
background_image,
background_gradient = 'from-blue-900 via-purple-900 to-indigo-900',
cta_text,
cta_url,
cta_secondary_text,
cta_secondary_url,
alignment = 'center'
} = Astro.props;
const alignmentClasses = {
left: 'items-start text-left',
center: 'items-center text-center',
right: 'items-end text-right'
};
---
<section
class={`relative min-h-[70vh] flex items-center justify-center py-20 bg-gradient-to-br ${background_gradient}`}
style={background_image ? `background-image: url('${background_image}'); background-size: cover; background-position: center;` : ''}
>
{background_image && <div class="absolute inset-0 bg-black/50"></div>}
<div class={`relative z-10 max-w-5xl mx-auto px-6 flex flex-col gap-6 ${alignmentClasses[alignment]}`}>
<h1 class="text-4xl md:text-6xl lg:text-7xl font-extrabold text-white leading-tight drop-shadow-2xl">
{title}
</h1>
{subtitle && (
<p class="text-xl md:text-2xl text-white/90 max-w-3xl leading-relaxed">
{subtitle}
</p>
)}
{(cta_text || cta_secondary_text) && (
<div class="flex flex-wrap gap-4 mt-6">
{cta_text && cta_url && (
<a
href={cta_url}
class="inline-flex items-center px-8 py-4 bg-white text-gray-900 rounded-xl font-bold text-lg shadow-xl hover:bg-gray-100 hover:scale-105 transition-all duration-300"
>
{cta_text}
<svg class="ml-2 w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</a>
)}
{cta_secondary_text && cta_secondary_url && (
<a
href={cta_secondary_url}
class="inline-flex items-center px-8 py-4 border-2 border-white text-white rounded-xl font-bold text-lg hover:bg-white/10 transition-all duration-300"
>
{cta_secondary_text}
</a>
)}
</div>
)}
</div>
<!-- Decorative elements -->
<div class="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-background to-transparent"></div>
</section>

View File

@@ -0,0 +1,51 @@
---
interface Props {
media_url: string;
media_type?: 'image' | 'video';
caption?: string;
alt?: string;
size?: 'sm' | 'md' | 'lg' | 'full';
rounded?: boolean;
shadow?: boolean;
}
const {
media_url,
media_type = 'image',
caption,
alt,
size = 'lg',
rounded = true,
shadow = true
} = Astro.props;
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-2xl',
lg: 'max-w-4xl',
full: 'max-w-full'
};
---
<figure class={`${sizeClasses[size]} mx-auto my-12 px-6`}>
{media_type === 'video' ? (
<video
src={media_url}
controls
class={`w-full ${rounded ? 'rounded-xl' : ''} ${shadow ? 'shadow-2xl' : ''}`}
/>
) : (
<img
src={media_url}
alt={alt || caption || ''}
loading="lazy"
class={`w-full h-auto ${rounded ? 'rounded-xl' : ''} ${shadow ? 'shadow-2xl' : ''} transition-transform duration-300 hover:scale-[1.02]`}
/>
)}
{caption && (
<figcaption class="mt-4 text-center text-gray-600 dark:text-gray-400 text-sm italic">
{caption}
</figcaption>
)}
</figure>

View File

@@ -0,0 +1,207 @@
---
import type { Post } from '@/types/schema';
interface Props {
title?: string;
subtitle?: string;
posts: Post[];
layout?: 'grid' | 'list' | 'featured';
show_excerpt?: boolean;
show_date?: boolean;
show_category?: boolean;
cta_text?: string;
cta_url?: string;
}
const {
title,
subtitle,
posts = [],
layout = 'grid',
show_excerpt = true,
show_date = true,
show_category = true,
cta_text,
cta_url
} = Astro.props;
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
---
<section class="max-w-6xl mx-auto px-6 py-16">
{(title || subtitle) && (
<div class="text-center mb-12">
{title && (
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{title}
</h2>
)}
{subtitle && (
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{subtitle}
</p>
)}
</div>
)}
{layout === 'featured' && posts.length > 0 ? (
<div class="grid md:grid-cols-2 gap-8">
<!-- Featured post -->
<a href={`/blog/${posts[0].slug}`} class="group block md:col-span-1 md:row-span-2">
<article class="h-full bg-white dark:bg-gray-800 rounded-2xl shadow-xl overflow-hidden hover:shadow-2xl transition-shadow duration-300">
{posts[0].featured_image && (
<div class="relative h-64 md:h-full overflow-hidden">
<img
src={posts[0].featured_image}
alt={posts[0].title}
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent"></div>
<div class="absolute bottom-0 left-0 right-0 p-6 text-white">
{show_category && posts[0].category && (
<span class="inline-block px-3 py-1 bg-primary rounded-full text-sm font-medium mb-3">
{posts[0].category}
</span>
)}
<h3 class="text-2xl font-bold mb-2">{posts[0].title}</h3>
{show_date && posts[0].published_at && (
<time class="text-sm text-gray-300">{formatDate(posts[0].published_at)}</time>
)}
</div>
</div>
)}
</article>
</a>
<!-- Other posts -->
<div class="space-y-6">
{posts.slice(1, 4).map((post) => (
<a href={`/blog/${post.slug}`} class="group block">
<article class="flex gap-4 bg-white dark:bg-gray-800 rounded-xl shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300">
{post.featured_image && (
<img
src={post.featured_image}
alt={post.title}
class="w-32 h-32 object-cover flex-shrink-0"
/>
)}
<div class="flex-1 p-4">
{show_category && post.category && (
<span class="text-xs font-medium text-primary uppercase tracking-wide">
{post.category}
</span>
)}
<h3 class="font-bold text-gray-900 dark:text-white group-hover:text-primary transition-colors mt-1">
{post.title}
</h3>
{show_date && post.published_at && (
<time class="text-sm text-gray-500 dark:text-gray-400 mt-2 block">
{formatDate(post.published_at)}
</time>
)}
</div>
</article>
</a>
))}
</div>
</div>
) : layout === 'list' ? (
<div class="space-y-6">
{posts.map((post) => (
<a href={`/blog/${post.slug}`} class="group block">
<article class="flex gap-6 bg-white dark:bg-gray-800 rounded-xl shadow-md p-6 hover:shadow-lg transition-shadow duration-300">
{post.featured_image && (
<img
src={post.featured_image}
alt={post.title}
class="w-48 h-32 object-cover rounded-lg flex-shrink-0"
/>
)}
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
{show_category && post.category && (
<span class="text-xs font-medium text-primary uppercase tracking-wide">
{post.category}
</span>
)}
{show_date && post.published_at && (
<time class="text-sm text-gray-500 dark:text-gray-400">
{formatDate(post.published_at)}
</time>
)}
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white group-hover:text-primary transition-colors mb-2">
{post.title}
</h3>
{show_excerpt && post.excerpt && (
<p class="text-gray-600 dark:text-gray-400 line-clamp-2">
{post.excerpt}
</p>
)}
</div>
</article>
</a>
))}
</div>
) : (
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{posts.map((post) => (
<a href={`/blog/${post.slug}`} class="group block">
<article class="bg-white dark:bg-gray-800 rounded-xl shadow-md overflow-hidden hover:shadow-xl transition-all duration-300 h-full flex flex-col">
{post.featured_image && (
<div class="relative h-48 overflow-hidden">
<img
src={post.featured_image}
alt={post.title}
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
/>
</div>
)}
<div class="p-6 flex-1 flex flex-col">
<div class="flex items-center gap-3 mb-3">
{show_category && post.category && (
<span class="text-xs font-medium text-primary uppercase tracking-wide">
{post.category}
</span>
)}
</div>
<h3 class="text-lg font-bold text-gray-900 dark:text-white group-hover:text-primary transition-colors mb-2">
{post.title}
</h3>
{show_excerpt && post.excerpt && (
<p class="text-gray-600 dark:text-gray-400 text-sm line-clamp-3 mb-4 flex-1">
{post.excerpt}
</p>
)}
{show_date && post.published_at && (
<time class="text-sm text-gray-500 dark:text-gray-400 mt-auto">
{formatDate(post.published_at)}
</time>
)}
</div>
</article>
</a>
))}
</div>
)}
{cta_text && cta_url && (
<div class="text-center mt-12">
<a
href={cta_url}
class="inline-flex items-center px-6 py-3 bg-primary text-white rounded-lg font-semibold hover:bg-primary/90 transition-colors"
>
{cta_text}
<svg class="ml-2 w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</a>
</div>
)}
</section>

View File

@@ -0,0 +1,86 @@
---
interface Props {
quote: string;
author?: string;
author_title?: string;
author_image?: string;
style?: 'simple' | 'card' | 'large';
}
const {
quote,
author,
author_title,
author_image,
style = 'card'
} = Astro.props;
---
{style === 'large' ? (
<section class="bg-gradient-to-br from-gray-900 to-gray-800 py-20 my-12">
<div class="max-w-4xl mx-auto px-6 text-center">
<svg class="w-12 h-12 text-primary/50 mx-auto mb-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z"/>
</svg>
<blockquote class="text-2xl md:text-4xl font-medium text-white leading-relaxed mb-8">
"{quote}"
</blockquote>
{author && (
<div class="flex items-center justify-center gap-4">
{author_image && (
<img
src={author_image}
alt={author}
class="w-16 h-16 rounded-full object-cover border-2 border-primary"
/>
)}
<div class="text-left">
<div class="text-white font-semibold text-lg">{author}</div>
{author_title && (
<div class="text-gray-400">{author_title}</div>
)}
</div>
</div>
)}
</div>
</section>
) : style === 'card' ? (
<div class="max-w-3xl mx-auto my-12 px-6">
<div class="bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 border-l-4 border-primary">
<blockquote class="text-xl text-gray-700 dark:text-gray-300 leading-relaxed italic mb-6">
"{quote}"
</blockquote>
{author && (
<div class="flex items-center gap-4">
{author_image && (
<img
src={author_image}
alt={author}
class="w-12 h-12 rounded-full object-cover"
/>
)}
<div>
<div class="font-semibold text-gray-900 dark:text-white">{author}</div>
{author_title && (
<div class="text-sm text-gray-500 dark:text-gray-400">{author_title}</div>
)}
</div>
</div>
)}
</div>
</div>
) : (
<blockquote class="max-w-3xl mx-auto my-12 px-6 border-l-4 border-primary pl-6">
<p class="text-2xl text-gray-700 dark:text-gray-300 italic leading-relaxed">
"{quote}"
</p>
{author && (
<footer class="mt-4 text-gray-600 dark:text-gray-400">
— {author}{author_title && `, ${author_title}`}
</footer>
)}
</blockquote>
)}

View File

@@ -0,0 +1,35 @@
---
interface Props {
content: string;
max_width?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
}
const { content, max_width = 'lg' } = Astro.props;
const widthClasses = {
sm: 'max-w-2xl',
md: 'max-w-3xl',
lg: 'max-w-4xl',
xl: 'max-w-5xl',
full: 'max-w-full'
};
---
<section class={`${widthClasses[max_width]} mx-auto px-6 py-12`}>
<div
class="prose prose-lg prose-gray dark:prose-invert max-w-none
prose-headings:font-bold prose-headings:text-gray-900 dark:prose-headings:text-white
prose-h2:text-3xl prose-h2:mt-12 prose-h2:mb-6
prose-h3:text-2xl prose-h3:mt-8 prose-h3:mb-4
prose-p:leading-relaxed prose-p:text-gray-700 dark:prose-p:text-gray-300
prose-a:text-primary prose-a:no-underline hover:prose-a:underline
prose-strong:text-gray-900 dark:prose-strong:text-white
prose-ul:space-y-2 prose-ol:space-y-2
prose-li:text-gray-700 dark:prose-li:text-gray-300
prose-blockquote:border-l-4 prose-blockquote:border-primary prose-blockquote:bg-gray-50 dark:prose-blockquote:bg-gray-800 prose-blockquote:py-4 prose-blockquote:px-6 prose-blockquote:rounded-r-lg
prose-table:border prose-table:rounded-lg prose-table:overflow-hidden
prose-th:bg-gray-100 dark:prose-th:bg-gray-800 prose-th:px-4 prose-th:py-3
prose-td:px-4 prose-td:py-3 prose-td:border-t"
set:html={content}
/>
</section>

View File

@@ -0,0 +1,86 @@
---
interface Step {
title: string;
description: string;
icon?: string;
}
interface Props {
title?: string;
subtitle?: string;
steps: Step[];
layout?: 'vertical' | 'horizontal';
}
const {
title,
subtitle,
steps = [],
layout = 'vertical'
} = Astro.props;
---
<section class="max-w-5xl mx-auto px-6 py-16">
{(title || subtitle) && (
<div class="text-center mb-12">
{title && (
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
{title}
</h2>
)}
{subtitle && (
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{subtitle}
</p>
)}
</div>
)}
{layout === 'horizontal' ? (
<div class="grid md:grid-cols-3 gap-8">
{steps.map((step, index) => (
<div class="relative text-center group">
<div class="w-16 h-16 bg-gradient-to-br from-primary to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-lg group-hover:scale-110 transition-transform duration-300">
<span class="text-2xl font-bold text-white">{index + 1}</span>
</div>
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
{step.title}
</h3>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
{step.description}
</p>
{index < steps.length - 1 && (
<div class="hidden md:block absolute top-8 left-[60%] w-[80%] h-0.5 bg-gradient-to-r from-primary/50 to-transparent"></div>
)}
</div>
))}
</div>
) : (
<div class="space-y-8">
{steps.map((step, index) => (
<div class="flex gap-6 group">
<div class="flex flex-col items-center">
<div class="w-12 h-12 bg-gradient-to-br from-primary to-purple-600 rounded-xl flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform duration-300">
<span class="text-xl font-bold text-white">{index + 1}</span>
</div>
{index < steps.length - 1 && (
<div class="w-0.5 flex-1 bg-gradient-to-b from-primary/50 to-transparent mt-4"></div>
)}
</div>
<div class="flex-1 pb-8">
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-2">
{step.title}
</h3>
<p class="text-gray-600 dark:text-gray-400 leading-relaxed">
{step.description}
</p>
</div>
</div>
))}
</div>
)}
</section>

View File

@@ -0,0 +1,39 @@
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-full 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 hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
success:
"border-transparent bg-green-500 text-white hover:bg-green-600",
warning:
"border-transparent bg-yellow-500 text-white hover:bg-yellow-600",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> { }
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

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

View File

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

View File

@@ -0,0 +1,116 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,38 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, label, error, ...props }, ref) => {
return (
<div className="space-y-1">
{label && (
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{label}
</label>
)}
<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",
error && "border-destructive",
className
)}
ref={ref}
{...props}
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
)
}
)
Input.displayName = "Input"
export { Input }

View File

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

View File

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

View File

@@ -0,0 +1,37 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string
error?: string
}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, label, error, ...props }, ref) => {
return (
<div className="space-y-1">
{label && (
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{label}
</label>
)}
<textarea
className={cn(
"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",
error && "border-destructive",
className
)}
ref={ref}
{...props}
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

21
frontend/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
/// <reference types="astro/client" />
interface ImportMetaEnv {
readonly PUBLIC_DIRECTUS_URL: string;
readonly DIRECTUS_ADMIN_TOKEN: string;
readonly PUBLIC_PLATFORM_DOMAIN: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
declare namespace App {
interface Locals {
siteId: string | null;
site: import('./types/schema').Site | null;
isAdminRoute: boolean;
isPlatformAdmin: boolean;
scope: 'super-admin' | 'tenant';
}
}

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>

View File

@@ -0,0 +1,50 @@
import {
createDirectus,
rest,
staticToken,
readItems,
readItem,
readSingleton,
createItem,
updateItem,
deleteItem,
aggregate
} from '@directus/sdk';
import type { SparkSchema } from '@/types/schema';
const DIRECTUS_URL = import.meta.env.PUBLIC_DIRECTUS_URL || 'http://localhost:8055';
const DIRECTUS_TOKEN = import.meta.env.DIRECTUS_ADMIN_TOKEN || '';
/**
* Creates a typed Directus client for the Spark Platform
*/
export function getDirectusClient(token?: string) {
const client = createDirectus<SparkSchema>(DIRECTUS_URL).with(rest());
if (token || DIRECTUS_TOKEN) {
return client.with(staticToken(token || DIRECTUS_TOKEN));
}
return client;
}
/**
* Helper to make authenticated requests
*/
export async function withAuth<T>(
token: string,
request: Promise<T>
): Promise<T> {
return request;
}
// Re-export SDK functions for convenience
export {
readItems,
readItem,
readSingleton,
createItem,
updateItem,
deleteItem,
aggregate
};

View File

@@ -0,0 +1,278 @@
import { getDirectusClient, readItems, readItem, readSingleton, aggregate } from './client';
import type { Page, Post, Site, Globals, Navigation } from '@/types/schema';
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: { _eq: siteId }
};
if (!options?.preview) {
filter.status = { _eq: 'published' };
}
try {
const pages = await directus.request(
readItems('pages', {
filter,
limit: 1,
fields: [
'id',
'title',
'permalink',
'status',
'seo_title',
'seo_description',
'seo_image',
{
blocks: {
id: true,
sort: true,
hide_block: true,
collection: true,
item: true
}
}
],
deep: {
blocks: { _sort: ['sort'], _filter: { hide_block: { _neq: true } } }
}
})
);
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: { _eq: siteId } },
limit: 1,
fields: ['*']
})
);
return globals?.[0] || null;
} catch (err) {
console.error('Error fetching globals:', err);
return null;
}
}
/**
* Fetch site navigation
*/
export async function fetchNavigation(siteId: string): Promise<Navigation[]> {
try {
const nav = await directus.request(
readItems('navigation', {
filter: { site: { _eq: siteId } },
sort: ['sort'],
fields: ['id', 'label', 'url', 'parent', 'target', 'sort']
})
);
return nav || [];
} 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: Post[]; total: number }> {
const limit = options?.limit || 10;
const page = options?.page || 1;
const offset = (page - 1) * limit;
const filter: Record<string, any> = {
site: { _eq: siteId },
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'
]
})
),
directus.request(
aggregate('posts', {
aggregate: { count: '*' },
query: { filter }
})
)
]);
return {
posts: posts || [],
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: { _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: { _eq: siteId } },
limit,
offset,
sort: ['-date_created'],
fields: ['*']
})
),
directus.request(
aggregate('generated_articles', {
aggregate: { count: '*' },
query: { filter: { site: { _eq: siteId } } }
})
)
]);
return {
articles: articles || [],
total: Number(countResult?.[0]?.count || 0)
};
} catch (err) {
console.error('Error fetching articles:', err);
return { articles: [], total: 0 };
}
}
/**
* Fetch SEO campaigns
*/
export async function fetchCampaigns(siteId?: string) {
const filter: Record<string, any> = {};
if (siteId) {
filter._or = [
{ site: { _eq: siteId } },
{ site: { _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() {
return directus.request(
readItems('locations_states', {
sort: ['name'],
fields: ['id', 'name', 'code']
})
);
}
export async function fetchCountiesByState(stateId: string) {
return directus.request(
readItems('locations_counties', {
filter: { state: { _eq: stateId } },
sort: ['name'],
fields: ['id', 'name', 'fips_code', 'population']
})
);
}
export async function fetchCitiesByCounty(countyId: string, limit = 50) {
return directus.request(
readItems('locations_cities', {
filter: { county: { _eq: countyId } },
sort: ['-population'],
limit,
fields: ['id', 'name', 'population', 'lat', 'lng', 'postal_code']
})
);
}

View File

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

View File

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

View File

@@ -0,0 +1,57 @@
import { defineMiddleware } from 'astro:middleware';
import { getDirectusClient, readItems } from './lib/directus/client';
/**
* Multi-Tenant Middleware
* Resolves siteId based on incoming domain and attaches it to SSR context.
* Supports both tenant admin (/admin) and public pages.
*/
export const onRequest = defineMiddleware(async (context, next) => {
const host = context.request.headers.get('host') || 'localhost';
const cleanHost = host.split(':')[0].replace(/^www\./, '');
const pathname = new URL(context.request.url).pathname;
// Determine if this is an admin route
const isAdminRoute = pathname.startsWith('/admin');
// Check if this is the platform admin (central admin)
const platformDomain = import.meta.env.PUBLIC_PLATFORM_DOMAIN || 'platform.local';
const isPlatformAdmin = cleanHost === platformDomain;
try {
const directus = getDirectusClient();
const sites = await directus.request(
readItems('sites', {
filter: {
_or: [
{ domain: { _eq: cleanHost } },
{ domain_aliases: { _contains: cleanHost } }
]
},
limit: 1,
fields: ['id', 'name', 'domain', 'settings']
})
);
if (!sites?.length) {
console.warn(`⚠ No site matched host: ${cleanHost}`);
context.locals.siteId = null;
context.locals.site = null;
} else {
context.locals.siteId = sites[0].id;
context.locals.site = sites[0];
}
} catch (err) {
console.error('❌ Middleware Error:', err);
context.locals.siteId = null;
context.locals.site = null;
}
// Set admin scope
context.locals.isAdminRoute = isAdminRoute;
context.locals.isPlatformAdmin = isPlatformAdmin;
context.locals.scope = isPlatformAdmin && isAdminRoute ? 'super-admin' : 'tenant';
return next();
});

View File

@@ -0,0 +1,58 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { fetchPageByPermalink, fetchSiteGlobals, fetchNavigation } from '../lib/directus/fetchers';
import BlockHero from '../components/blocks/BlockHero.astro';
import BlockRichText from '../components/blocks/BlockRichText.astro';
import BlockColumns from '../components/blocks/BlockColumns.astro';
import BlockMedia from '../components/blocks/BlockMedia.astro';
import BlockSteps from '../components/blocks/BlockSteps.astro';
import BlockQuote from '../components/blocks/BlockQuote.astro';
import BlockGallery from '../components/blocks/BlockGallery.astro';
import BlockFAQ from '../components/blocks/BlockFAQ.astro';
import BlockPosts from '../components/blocks/BlockPosts.astro';
import BlockForm from '../components/blocks/BlockForm.astro';
const siteId = Astro.locals.siteId;
const permalink = '/' + (Astro.params.slug || '');
// Fetch data
const [globals, navigation, page] = await Promise.all([
siteId ? fetchSiteGlobals(siteId) : null,
siteId ? fetchNavigation(siteId) : [],
siteId ? fetchPageByPermalink(permalink, siteId) : null
]);
if (!page) {
return Astro.redirect('/404');
}
// Block component map
const blockComponents: Record<string, any> = {
block_hero: BlockHero,
block_richtext: BlockRichText,
block_columns: BlockColumns,
block_media: BlockMedia,
block_steps: BlockSteps,
block_quote: BlockQuote,
block_gallery: BlockGallery,
block_faq: BlockFAQ,
block_posts: BlockPosts,
block_form: BlockForm,
};
---
<BaseLayout
title={page.seo_title || page.title}
description={page.seo_description}
image={page.seo_image}
globals={globals}
navigation={navigation}
>
{page.blocks?.map((block) => {
const Component = blockComponents[block.collection];
if (Component) {
return <Component {...block.item} />;
}
return null;
})}
</BaseLayout>

View File

@@ -0,0 +1,148 @@
---
import AdminLayout from '../../layouts/AdminLayout.astro';
---
<AdminLayout title="Dashboard">
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div class="bg-gray-800 rounded-xl p-6 border border-gray-700">
<div class="flex items-center justify-between mb-4">
<span class="text-gray-400 text-sm font-medium">Total Pages</span>
<div class="w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-blue-500" 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>
</div>
</div>
<div class="text-3xl font-bold text-white">12</div>
<div class="text-sm text-green-500 mt-2">+2 this week</div>
</div>
<div class="bg-gray-800 rounded-xl p-6 border border-gray-700">
<div class="flex items-center justify-between mb-4">
<span class="text-gray-400 text-sm font-medium">Total Posts</span>
<div class="w-10 h-10 bg-purple-500/20 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-purple-500" 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>
</div>
</div>
<div class="text-3xl font-bold text-white">48</div>
<div class="text-sm text-green-500 mt-2">+8 this week</div>
</div>
<div class="bg-gray-800 rounded-xl p-6 border border-gray-700">
<div class="flex items-center justify-between mb-4">
<span class="text-gray-400 text-sm font-medium">Generated Articles</span>
<div class="w-10 h-10 bg-green-500/20 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-green-500" 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>
</div>
</div>
<div class="text-3xl font-bold text-white">256</div>
<div class="text-sm text-green-500 mt-2">+45 this week</div>
</div>
<div class="bg-gray-800 rounded-xl p-6 border border-gray-700">
<div class="flex items-center justify-between mb-4">
<span class="text-gray-400 text-sm font-medium">Total Leads</span>
<div class="w-10 h-10 bg-orange-500/20 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-orange-500" 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>
</div>
</div>
<div class="text-3xl font-bold text-white">89</div>
<div class="text-sm text-green-500 mt-2">+12 this week</div>
</div>
</div>
<div class="grid lg:grid-cols-2 gap-8">
<!-- Quick Actions -->
<div class="bg-gray-800 rounded-xl p-6 border border-gray-700">
<h2 class="text-xl font-bold text-white mb-6">Quick Actions</h2>
<div class="grid grid-cols-2 gap-4">
<a href="/admin/pages/new" class="flex items-center gap-3 p-4 bg-gray-700/50 rounded-lg hover:bg-gray-700 transition-colors">
<div class="w-10 h-10 bg-blue-500 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</div>
<span class="text-white font-medium">New Page</span>
</a>
<a href="/admin/posts/new" class="flex items-center gap-3 p-4 bg-gray-700/50 rounded-lg hover:bg-gray-700 transition-colors">
<div class="w-10 h-10 bg-purple-500 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</div>
<span class="text-white font-medium">New Post</span>
</a>
<a href="/admin/seo/campaigns/new" class="flex items-center gap-3 p-4 bg-gray-700/50 rounded-lg hover:bg-gray-700 transition-colors">
<div class="w-10 h-10 bg-green-500 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
</div>
<span class="text-white font-medium">New Campaign</span>
</a>
<a href="/admin/seo/articles" class="flex items-center gap-3 p-4 bg-gray-700/50 rounded-lg hover:bg-gray-700 transition-colors">
<div class="w-10 h-10 bg-orange-500 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 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-white font-medium">Generate Articles</span>
</a>
</div>
</div>
<!-- Recent Activity -->
<div class="bg-gray-800 rounded-xl p-6 border border-gray-700">
<h2 class="text-xl font-bold text-white mb-6">Recent Activity</h2>
<div class="space-y-4">
<div class="flex items-center gap-4">
<div class="w-10 h-10 bg-green-500/20 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<div class="flex-1">
<p class="text-white font-medium">Article generated</p>
<p class="text-gray-400 text-sm">SEO Campaign: "Local Dental"</p>
</div>
<span class="text-gray-500 text-sm">2m ago</span>
</div>
<div class="flex items-center gap-4">
<div class="w-10 h-10 bg-blue-500/20 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-blue-500" 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>
</div>
<div class="flex-1">
<p class="text-white font-medium">Page updated</p>
<p class="text-gray-400 text-sm">"About Us" page</p>
</div>
<span class="text-gray-500 text-sm">1h ago</span>
</div>
<div class="flex items-center gap-4">
<div class="w-10 h-10 bg-orange-500/20 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<div class="flex-1">
<p class="text-white font-medium">New lead captured</p>
<p class="text-gray-400 text-sm">john@example.com</p>
</div>
<span class="text-gray-500 text-sm">3h ago</span>
</div>
</div>
</div>
</div>
</AdminLayout>

View File

@@ -0,0 +1,8 @@
---
import AdminLayout from '../../../layouts/AdminLayout.astro';
import LocationBrowser from '../../../components/admin/LocationBrowser';
---
<AdminLayout title="Locations">
<LocationBrowser client:load />
</AdminLayout>

View File

@@ -0,0 +1,8 @@
---
import AdminLayout from '../../../../layouts/AdminLayout.astro';
import ImageTemplateEditor from '../../../../components/admin/ImageTemplateEditor';
---
<AdminLayout title="Image Templates">
<ImageTemplateEditor client:load />
</AdminLayout>

View File

@@ -0,0 +1,8 @@
---
import AdminLayout from '../../../layouts/AdminLayout.astro';
import ArticleGenerator from '../../../components/admin/ArticleGenerator';
---
<AdminLayout title="Generated Articles">
<ArticleGenerator client:load />
</AdminLayout>

View File

@@ -0,0 +1,8 @@
---
import AdminLayout from '../../../layouts/AdminLayout.astro';
import CampaignManager from '../../../components/admin/CampaignManager';
---
<AdminLayout title="SEO Campaigns">
<CampaignManager client:load />
</AdminLayout>

View File

@@ -0,0 +1,81 @@
import type { APIRoute } from 'astro';
import { getDirectusClient, readItems, createItem } from '@/lib/directus/client';
export const GET: APIRoute = async ({ locals }) => {
try {
const directus = getDirectusClient();
const siteId = locals.siteId;
const filter: Record<string, any> = {};
if (siteId) {
filter._or = [
{ site: { _eq: siteId } },
{ site: { _null: true } }
];
}
const campaigns = await directus.request(
readItems('campaign_masters', {
filter,
sort: ['-date_created'],
fields: ['id', 'name', 'headline_spintax_root', 'location_mode', 'status', 'date_created']
})
);
return new Response(
JSON.stringify({ campaigns }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error fetching campaigns:', error);
return new Response(
JSON.stringify({ campaigns: [], error: 'Failed to fetch campaigns' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
};
export const POST: APIRoute = async ({ request, locals }) => {
try {
const data = await request.json();
const siteId = locals.siteId;
if (!data.name || !data.headline_spintax_root) {
return new Response(
JSON.stringify({ error: 'Name and headline spintax are required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const directus = getDirectusClient();
let nicheVariables = {};
if (data.niche_variables) {
try {
nicheVariables = JSON.parse(data.niche_variables);
} catch { }
}
const campaign = await directus.request(
createItem('campaign_masters', {
site: siteId,
name: data.name,
headline_spintax_root: data.headline_spintax_root,
niche_variables: nicheVariables,
location_mode: data.location_mode || 'none',
status: 'active'
})
);
return new Response(
JSON.stringify({ success: true, campaign }),
{ status: 201, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error creating campaign:', error);
return new Response(
JSON.stringify({ error: 'Failed to create campaign' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};

View File

@@ -0,0 +1,40 @@
import type { APIRoute } from 'astro';
import { getDirectusClient, createItem } from '@/lib/directus/client';
export const POST: APIRoute = async ({ request, locals }) => {
try {
const data = await request.json();
const siteId = locals.siteId;
if (!data.email) {
return new Response(
JSON.stringify({ error: 'Email is required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const directus = getDirectusClient();
await directus.request(
createItem('leads', {
site: siteId,
name: data.name || '',
email: data.email,
phone: data.phone || '',
message: data.message || '',
source: data.source || 'website'
})
);
return new Response(
JSON.stringify({ success: true }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Lead submission error:', error);
return new Response(
JSON.stringify({ error: 'Failed to submit lead' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};

View File

@@ -0,0 +1,37 @@
import type { APIRoute } from 'astro';
import { getDirectusClient, readItems } from '@/lib/directus/client';
export const GET: APIRoute = async ({ url }) => {
try {
const countyId = url.searchParams.get('county');
if (!countyId) {
return new Response(
JSON.stringify({ error: 'County ID is required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const directus = getDirectusClient();
const cities = await directus.request(
readItems('locations_cities', {
filter: { county: { _eq: countyId } },
sort: ['-population'],
limit: 50,
fields: ['id', 'name', 'population', 'lat', 'lng', 'postal_code']
})
);
return new Response(
JSON.stringify({ cities }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error fetching cities:', error);
return new Response(
JSON.stringify({ cities: [], error: 'Failed to fetch cities' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
};

View File

@@ -0,0 +1,36 @@
import type { APIRoute } from 'astro';
import { getDirectusClient, readItems } from '@/lib/directus/client';
export const GET: APIRoute = async ({ url }) => {
try {
const stateId = url.searchParams.get('state');
if (!stateId) {
return new Response(
JSON.stringify({ error: 'State ID is required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const directus = getDirectusClient();
const counties = await directus.request(
readItems('locations_counties', {
filter: { state: { _eq: stateId } },
sort: ['name'],
fields: ['id', 'name', 'fips_code', 'population']
})
);
return new Response(
JSON.stringify({ counties }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error fetching counties:', error);
return new Response(
JSON.stringify({ counties: [], error: 'Failed to fetch counties' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
};

View File

@@ -0,0 +1,26 @@
import type { APIRoute } from 'astro';
import { getDirectusClient, readItems } from '@/lib/directus/client';
export const GET: APIRoute = async () => {
try {
const directus = getDirectusClient();
const states = await directus.request(
readItems('locations_states', {
sort: ['name'],
fields: ['id', 'name', 'code']
})
);
return new Response(
JSON.stringify({ states }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error fetching states:', error);
return new Response(
JSON.stringify({ states: [], error: 'Failed to fetch states' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
};

View File

@@ -0,0 +1,72 @@
import type { APIRoute } from 'astro';
import { getDirectusClient, readItems, createItem, updateItem } from '@/lib/directus/client';
export const GET: APIRoute = async ({ locals }) => {
try {
const directus = getDirectusClient();
const siteId = locals.siteId;
const filter: Record<string, any> = {};
if (siteId) {
filter._or = [
{ site: { _eq: siteId } },
{ site: { _null: true } }
];
}
const templates = await directus.request(
readItems('image_templates', {
filter,
sort: ['-is_default', 'name'],
fields: ['id', 'name', 'svg_source', 'is_default', 'preview']
})
);
return new Response(
JSON.stringify({ templates }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error fetching templates:', error);
return new Response(
JSON.stringify({ templates: [], error: 'Failed to fetch templates' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
};
export const POST: APIRoute = async ({ request, locals }) => {
try {
const data = await request.json();
const siteId = locals.siteId;
if (!data.name || !data.svg_source) {
return new Response(
JSON.stringify({ error: 'Name and SVG source are required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const directus = getDirectusClient();
const template = await directus.request(
createItem('image_templates', {
site: siteId,
name: data.name,
svg_source: data.svg_source,
is_default: false
})
);
return new Response(
JSON.stringify({ success: true, template }),
{ status: 201, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error creating template:', error);
return new Response(
JSON.stringify({ error: 'Failed to create template' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};

View File

@@ -0,0 +1,43 @@
import type { APIRoute } from 'astro';
import { getDirectusClient, readItems } from '@/lib/directus/client';
export const GET: APIRoute = async ({ locals }) => {
try {
const directus = getDirectusClient();
const siteId = locals.siteId;
const filter: Record<string, any> = {};
if (siteId) {
filter.site = { _eq: siteId };
}
const articles = await directus.request(
readItems('generated_articles', {
filter,
sort: ['-date_created'],
limit: 100,
fields: [
'id',
'headline',
'meta_title',
'word_count',
'is_published',
'location_city',
'location_state',
'date_created'
]
})
);
return new Response(
JSON.stringify({ articles }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error fetching articles:', error);
return new Response(
JSON.stringify({ articles: [], error: 'Failed to fetch articles' }),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
}
};

View File

@@ -0,0 +1,268 @@
import type { APIRoute } from 'astro';
import { getDirectusClient, readItems, createItem, updateItem } from '@/lib/directus/client';
import { parseSpintaxRandom, injectVariables } from '@/lib/seo/cartesian';
import type { VariableMap } from '@/types/cartesian';
/**
* Fragment types for the 6-pillar content structure + intro and FAQ
*/
const FRAGMENT_TYPES = [
'intro_hook',
'pillar_1_keyword',
'pillar_2_uniqueness',
'pillar_3_relevance',
'pillar_4_quality',
'pillar_5_authority',
'pillar_6_backlinks',
'faq_section'
];
/**
* Count words in text (strip HTML first)
*/
function countWords(text: string): number {
return text.replace(/<[^>]*>/g, '').split(/\s+/).filter(Boolean).length;
}
/**
* Generate Article API
*
* Assembles SEO articles by:
* 1. Pulling an available headline from inventory
* 2. Fetching location data for variable injection
* 3. Selecting random fragments for each 6-pillar section
* 4. Processing spintax within fragments (random selection)
* 5. Injecting all variables (niche + location)
* 6. Stitching into full HTML body
*/
export const POST: APIRoute = async ({ request, locals }) => {
try {
const data = await request.json();
const { campaign_id, batch_size = 1 } = data;
const siteId = locals.siteId;
if (!campaign_id) {
return new Response(
JSON.stringify({ error: 'Campaign ID is required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const directus = getDirectusClient();
// Get campaign configuration
const campaigns = await directus.request(
readItems('campaign_masters', {
filter: { id: { _eq: campaign_id } },
limit: 1,
fields: ['*']
})
);
if (!campaigns?.length) {
return new Response(
JSON.stringify({ error: 'Campaign not found' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
const campaign = campaigns[0] as any;
const nicheVariables: VariableMap = campaign.niche_variables || {};
const generatedArticles = [];
const effectiveBatchSize = Math.min(batch_size, 50);
for (let i = 0; i < effectiveBatchSize; i++) {
// Get next available headline
const headlines = await directus.request(
readItems('headline_inventory', {
filter: {
campaign: { _eq: campaign_id },
status: { _eq: 'available' }
},
limit: 1,
fields: ['id', 'final_title_text', 'location_data']
})
);
if (!headlines?.length) {
break; // No more headlines available
}
const headline = headlines[0] as any;
// Get location variables (from headline or fetch fresh)
let locationVars: VariableMap = {};
if (headline.location_data) {
// Use location from headline (set during headline generation)
const loc = headline.location_data;
locationVars = {
city: loc.city || '',
county: loc.county || '',
state: loc.state || '',
state_code: loc.stateCode || ''
};
} else if (campaign.location_mode === 'city') {
// Fetch random city
const cities = await directus.request(
readItems('locations_cities', {
limit: 1,
offset: Math.floor(Math.random() * 100),
fields: ['name', 'population', { county: ['name'] }, { state: ['name', 'code'] }]
})
);
if (cities?.length) {
const city = cities[0] as any;
locationVars = {
city: city.name,
county: city.county?.name || '',
state: city.state?.name || '',
state_code: city.state?.code || '',
population: String(city.population || '')
};
}
} else if (campaign.location_mode === 'county') {
const counties = await directus.request(
readItems('locations_counties', {
limit: 1,
offset: Math.floor(Math.random() * 100),
fields: ['name', { state: ['name', 'code'] }]
})
);
if (counties?.length) {
const county = counties[0] as any;
locationVars = {
county: county.name,
state: county.state?.name || '',
state_code: county.state?.code || ''
};
}
} else if (campaign.location_mode === 'state') {
const states = await directus.request(
readItems('locations_states', {
limit: 1,
offset: Math.floor(Math.random() * 50),
fields: ['name', 'code']
})
);
if (states?.length) {
const state = states[0] as any;
locationVars = {
state: state.name,
state_code: state.code
};
}
}
// Merge all variables for injection
const allVariables: VariableMap = { ...nicheVariables, ...locationVars };
// Assemble article from fragments
const fragments: string[] = [];
for (const fragmentType of FRAGMENT_TYPES) {
const typeFragments = await directus.request(
readItems('content_fragments', {
filter: {
campaign: { _eq: campaign_id },
fragment_type: { _eq: fragmentType }
},
fields: ['content_body']
})
);
if (typeFragments?.length) {
// Pick random fragment for variation
const randomFragment = typeFragments[
Math.floor(Math.random() * typeFragments.length)
] as any;
let content = randomFragment.content_body;
// Process spintax (random selection within fragments)
content = parseSpintaxRandom(content);
// Inject all variables
content = injectVariables(content, allVariables);
fragments.push(content);
}
}
// Assemble full article HTML
const fullHtmlBody = fragments.join('\n\n');
const wordCount = countWords(fullHtmlBody);
// Generate meta title and description
const processedHeadline = injectVariables(headline.final_title_text, allVariables);
const metaTitle = processedHeadline.substring(0, 70);
const metaDescription = fragments[0]
? fragments[0].replace(/<[^>]*>/g, '').substring(0, 155)
: metaTitle;
// Create article record
const article = await directus.request(
createItem('generated_articles', {
site: siteId || campaign.site,
campaign: campaign_id,
headline: processedHeadline,
meta_title: metaTitle,
meta_description: metaDescription,
full_html_body: fullHtmlBody,
word_count: wordCount,
is_published: false,
location_city: locationVars.city || null,
location_county: locationVars.county || null,
location_state: locationVars.state || null
})
);
// Mark headline as used
await directus.request(
updateItem('headline_inventory', headline.id, {
status: 'used',
used_on_article: (article as any).id
})
);
generatedArticles.push({
id: (article as any).id,
headline: processedHeadline,
word_count: wordCount,
location: locationVars.city || locationVars.county || locationVars.state || null
});
}
// Get remaining available headlines count
const remainingHeadlines = await directus.request(
readItems('headline_inventory', {
filter: {
campaign: { _eq: campaign_id },
status: { _eq: 'available' }
},
aggregate: { count: '*' }
})
);
const remainingCount = (remainingHeadlines as any)?.[0]?.count || 0;
return new Response(
JSON.stringify({
success: true,
generated: generatedArticles.length,
articles: generatedArticles,
remaining_headlines: parseInt(remainingCount, 10)
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error generating article:', error);
return new Response(
JSON.stringify({ error: 'Failed to generate article' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};

View File

@@ -0,0 +1,380 @@
import type { APIRoute } from 'astro';
import { getDirectusClient, readItems, createItem } from '@/lib/directus/client';
import {
extractSpintaxSlots,
calculateTotalCombinations,
generateWithLocations,
getCartesianMetadata,
explodeSpintax
} from '@/lib/seo/cartesian';
import type { LocationEntry, CartesianResult } from '@/types/cartesian';
/**
* Generate Headlines API
*
* Generates all Cartesian product combinations from:
* - Campaign spintax template
* - Location data (if location_mode is set)
*
* Uses the n^k formula where:
* - n = number of options per spintax slot
* - k = number of slots
* - Final total = (n₁ × n₂ × ... × nₖ) × location_count
*/
export const POST: APIRoute = async ({ request }) => {
try {
const data = await request.json();
const {
campaign_id,
max_headlines = 10000,
batch_size = 500,
offset = 0
} = data;
if (!campaign_id) {
return new Response(
JSON.stringify({ error: 'Campaign ID is required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const directus = getDirectusClient();
// Get campaign
const campaigns = await directus.request(
readItems('campaign_masters', {
filter: { id: { _eq: campaign_id } },
limit: 1,
fields: [
'id',
'headline_spintax_root',
'niche_variables',
'location_mode',
'location_target'
]
})
);
if (!campaigns?.length) {
return new Response(
JSON.stringify({ error: 'Campaign not found' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
const campaign = campaigns[0] as any;
const spintax = campaign.headline_spintax_root;
const nicheVariables = campaign.niche_variables || {};
const locationMode = campaign.location_mode || 'none';
// Fetch locations based on mode
let locations: LocationEntry[] = [];
if (locationMode !== 'none') {
locations = await fetchLocations(
directus,
locationMode,
campaign.location_target
);
}
// Calculate metadata BEFORE generation
const metadata = getCartesianMetadata(
spintax,
locations.length,
max_headlines
);
// Check existing headlines to avoid duplicates
const existing = await directus.request(
readItems('headline_inventory', {
filter: { campaign: { _eq: campaign_id } },
fields: ['final_title_text']
})
);
const existingTitles = new Set(
existing?.map((h: any) => h.final_title_text) || []
);
// Generate Cartesian product headlines
const generator = generateWithLocations(
spintax,
locations,
nicheVariables,
{ maxCombinations: max_headlines, offset }
);
// Insert new headlines in batches
let insertedCount = 0;
let skippedCount = 0;
let processedCount = 0;
const batch: CartesianResult[] = [];
for (const result of generator) {
processedCount++;
if (existingTitles.has(result.text)) {
skippedCount++;
continue;
}
batch.push(result);
// Insert batch when full
if (batch.length >= batch_size) {
insertedCount += await insertHeadlineBatch(
directus,
campaign_id,
batch
);
batch.length = 0; // Clear batch
}
// Safety limit
if (insertedCount >= max_headlines) break;
}
// Insert remaining batch
if (batch.length > 0) {
insertedCount += await insertHeadlineBatch(
directus,
campaign_id,
batch
);
}
return new Response(
JSON.stringify({
success: true,
metadata: {
template: spintax,
slotCount: metadata.slotCount,
spintaxCombinations: metadata.totalSpintaxCombinations,
locationCount: locations.length,
totalPossible: metadata.totalPossibleCombinations,
wasTruncated: metadata.wasTruncated
},
results: {
processed: processedCount,
inserted: insertedCount,
skipped: skippedCount,
alreadyExisted: existingTitles.size
}
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error generating headlines:', error);
return new Response(
JSON.stringify({ error: 'Failed to generate headlines' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};
/**
* Fetch locations based on mode and optional target filter
*/
async function fetchLocations(
directus: any,
mode: string,
targetId?: string
): Promise<LocationEntry[]> {
try {
switch (mode) {
case 'state': {
const filter: any = targetId
? { id: { _eq: targetId } }
: {};
const states = await directus.request(
readItems('locations_states', {
filter,
fields: ['id', 'name', 'code'],
limit: 100
})
);
return (states || []).map((s: any) => ({
id: s.id,
state: s.name,
stateCode: s.code
}));
}
case 'county': {
const filter: any = targetId
? { state: { _eq: targetId } }
: {};
const counties = await directus.request(
readItems('locations_counties', {
filter,
fields: ['id', 'name', 'population', { state: ['name', 'code'] }],
sort: ['-population'],
limit: 500
})
);
return (counties || []).map((c: any) => ({
id: c.id,
county: c.name,
state: c.state?.name || '',
stateCode: c.state?.code || '',
population: c.population
}));
}
case 'city': {
const filter: any = {};
// If target is set, filter to that state's cities
if (targetId) {
// Check if target is a state or county
const states = await directus.request(
readItems('locations_states', {
filter: { id: { _eq: targetId } },
limit: 1
})
);
if (states?.length) {
filter.state = { _eq: targetId };
} else {
filter.county = { _eq: targetId };
}
}
const cities = await directus.request(
readItems('locations_cities', {
filter,
fields: [
'id',
'name',
'population',
{ county: ['name'] },
{ state: ['name', 'code'] }
],
sort: ['-population'],
limit: 1000 // Top 1000 cities
})
);
return (cities || []).map((c: any) => ({
id: c.id,
city: c.name,
county: c.county?.name || '',
state: c.state?.name || '',
stateCode: c.state?.code || '',
population: c.population
}));
}
default:
return [];
}
} catch (error) {
console.error('Error fetching locations:', error);
return [];
}
}
/**
* Insert a batch of headlines into the database
*/
async function insertHeadlineBatch(
directus: any,
campaignId: string,
batch: CartesianResult[]
): Promise<number> {
let count = 0;
for (const result of batch) {
try {
await directus.request(
createItem('headline_inventory', {
campaign: campaignId,
final_title_text: result.text,
status: 'available',
location_data: result.location || null
})
);
count++;
} catch (error) {
// Skip duplicates or errors
console.error('Failed to insert headline:', error);
}
}
return count;
}
/**
* Preview endpoint - shows what WOULD be generated without inserting
*/
export const GET: APIRoute = async ({ url }) => {
try {
const campaignId = url.searchParams.get('campaign_id');
const previewCount = parseInt(url.searchParams.get('preview') || '10');
if (!campaignId) {
return new Response(
JSON.stringify({ error: 'campaign_id is required' }),
{ status: 400, headers: { 'Content-Type': 'application/json' } }
);
}
const directus = getDirectusClient();
// Get campaign
const campaigns = await directus.request(
readItems('campaign_masters', {
filter: { id: { _eq: campaignId } },
limit: 1,
fields: ['headline_spintax_root', 'location_mode', 'location_target']
})
);
if (!campaigns?.length) {
return new Response(
JSON.stringify({ error: 'Campaign not found' }),
{ status: 404, headers: { 'Content-Type': 'application/json' } }
);
}
const campaign = campaigns[0] as any;
const spintax = campaign.headline_spintax_root;
// Get location count
let locationCount = 1;
if (campaign.location_mode !== 'none') {
const locations = await fetchLocations(
directus,
campaign.location_mode,
campaign.location_target
);
locationCount = locations.length;
}
// Get metadata
const metadata = getCartesianMetadata(spintax, locationCount);
// Generate preview samples
const samples = explodeSpintax(spintax, previewCount);
return new Response(
JSON.stringify({
metadata,
preview: samples
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } }
);
} catch (error) {
console.error('Error previewing headlines:', error);
return new Response(
JSON.stringify({ error: 'Failed to preview headlines' }),
{ status: 500, headers: { 'Content-Type': 'application/json' } }
);
}
};

View File

@@ -0,0 +1,75 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { fetchPageByPermalink, fetchSiteGlobals, fetchNavigation } from '../lib/directus/fetchers';
import BlockHero from '../components/blocks/BlockHero.astro';
import BlockRichText from '../components/blocks/BlockRichText.astro';
import BlockColumns from '../components/blocks/BlockColumns.astro';
import BlockMedia from '../components/blocks/BlockMedia.astro';
import BlockSteps from '../components/blocks/BlockSteps.astro';
import BlockQuote from '../components/blocks/BlockQuote.astro';
import BlockGallery from '../components/blocks/BlockGallery.astro';
import BlockFAQ from '../components/blocks/BlockFAQ.astro';
import BlockPosts from '../components/blocks/BlockPosts.astro';
import BlockForm from '../components/blocks/BlockForm.astro';
const siteId = Astro.locals.siteId;
// Fetch data
const [globals, navigation, page] = await Promise.all([
siteId ? fetchSiteGlobals(siteId) : null,
siteId ? fetchNavigation(siteId) : [],
siteId ? fetchPageByPermalink('/', siteId) : null
]);
// Block component map
const blockComponents: Record<string, any> = {
block_hero: BlockHero,
block_richtext: BlockRichText,
block_columns: BlockColumns,
block_media: BlockMedia,
block_steps: BlockSteps,
block_quote: BlockQuote,
block_gallery: BlockGallery,
block_faq: BlockFAQ,
block_posts: BlockPosts,
block_form: BlockForm,
};
---
<BaseLayout
title={page?.seo_title || page?.title || 'Welcome'}
description={page?.seo_description || globals?.site_tagline}
image={page?.seo_image}
globals={globals}
navigation={navigation}
>
{page?.blocks?.map((block) => {
const Component = blockComponents[block.collection];
if (Component) {
return <Component {...block.item} />;
}
return null;
})}
{!page && (
<section class="min-h-[70vh] flex items-center justify-center bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900">
<div class="text-center text-white px-6">
<h1 class="text-5xl md:text-7xl font-extrabold mb-6 drop-shadow-2xl">
Welcome to Spark Platform
</h1>
<p class="text-xl text-white/80 mb-8 max-w-2xl mx-auto">
The ultimate multi-tenant website builder with SEO automation, content generation, and lead capture.
</p>
<a
href="/admin"
class="inline-flex items-center px-8 py-4 bg-white text-gray-900 rounded-xl font-bold text-lg shadow-xl hover:bg-gray-100 transition-all"
>
Go to Admin Dashboard
<svg class="ml-2 w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</a>
</div>
</section>
)}
</BaseLayout>

View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,109 @@
/**
* Cartesian Permutation Type Definitions
*
* Types for spintax parsing, n^k combinations, and location cross-products.
*/
/**
* Represents a single spintax slot found in text
* Example: "{Best|Top|Leading}" becomes:
* { original: "{Best|Top|Leading}", options: ["Best", "Top", "Leading"], position: 0 }
*/
export interface SpintaxSlot {
/** The original matched string including braces */
original: string;
/** Array of options extracted from the slot */
options: string[];
/** Position index in the template */
position: number;
/** Start character index in original text */
startIndex: number;
/** End character index in original text */
endIndex: number;
}
/**
* Configuration for Cartesian product generation
*/
export interface CartesianConfig {
/** Maximum number of combinations to generate (safety limit) */
maxCombinations: number;
/** Whether to include location data in cross-product */
includeLocations: boolean;
/** How to handle locations: state, county, city, or none */
locationMode: 'state' | 'county' | 'city' | 'none';
/** Optional: limit to specific state/county */
locationTargetId?: string;
/** Batch size for processing */
batchSize: number;
/** Starting offset for pagination */
offset: number;
}
/**
* Default configuration
*/
export const DEFAULT_CARTESIAN_CONFIG: CartesianConfig = {
maxCombinations: 10000,
includeLocations: false,
locationMode: 'none',
batchSize: 500,
offset: 0
};
/**
* A single result from Cartesian product generation
*/
export interface CartesianResult {
/** The final assembled text */
text: string;
/** Map of slot identifier to chosen value */
slotValues: Record<string, string>;
/** Location data if applicable */
location?: {
city?: string;
county?: string;
state?: string;
stateCode?: string;
id?: string;
};
/** Index in the full Cartesian product sequence */
index: number;
}
/**
* Metadata about a Cartesian product operation
*/
export interface CartesianMetadata {
/** Template before expansion */
template: string;
/** Number of slots found */
slotCount: number;
/** Product of all slot option counts (n^k formula result) */
totalSpintaxCombinations: number;
/** Number of locations in cross-product */
locationCount: number;
/** Total possible combinations (spintax × locations) */
totalPossibleCombinations: number;
/** Actual count generated (respecting maxCombinations) */
generatedCount: number;
/** Whether generation was truncated due to limit */
wasTruncated: boolean;
}
/**
* Location data structure for cross-product
*/
export interface LocationEntry {
id: string;
city?: string;
county?: string;
state: string;
stateCode: string;
population?: number;
}
/**
* Variable map for injection
*/
export type VariableMap = Record<string, string>;

View File

@@ -0,0 +1,264 @@
/**
* Spark Platform - Directus Schema Types
*/
export interface Site {
id: string;
name: string;
domain: string;
domain_aliases?: string[];
settings?: Record<string, any>;
status: 'active' | 'inactive';
date_created?: string;
date_updated?: string;
}
export interface Page {
id: string;
site: string | Site;
title: string;
permalink: string;
status: 'draft' | 'published' | 'archived';
seo_title?: string;
seo_description?: string;
seo_image?: string;
blocks?: PageBlock[];
date_created?: string;
date_updated?: string;
}
export interface PageBlock {
id: string;
sort: number;
hide_block: boolean;
collection: string;
item: any;
}
export interface Post {
id: string;
site: string | Site;
title: string;
slug: string;
excerpt?: string;
content: string;
featured_image?: string;
status: 'draft' | 'published' | 'archived';
published_at?: string;
category?: string;
author?: string;
seo_title?: string;
seo_description?: string;
date_created?: string;
date_updated?: string;
}
export interface Globals {
id: string;
site: string | Site;
site_name?: string;
site_tagline?: string;
logo?: string;
favicon?: string;
primary_color?: string;
secondary_color?: string;
footer_text?: string;
social_links?: SocialLink[];
scripts_head?: string;
scripts_body?: string;
}
export interface SocialLink {
platform: string;
url: string;
}
export interface Navigation {
id: string;
site: string | Site;
label: string;
url: string;
target?: '_self' | '_blank';
parent?: string | Navigation;
sort: number;
}
export interface Author {
id: string;
name: string;
bio?: string;
avatar?: string;
email?: string;
}
// SEO Engine Types
export interface CampaignMaster {
id: string;
site?: string | Site;
name: string;
headline_spintax_root: string;
niche_variables?: Record<string, string>;
location_mode: 'none' | 'state' | 'county' | 'city';
location_target?: string;
batch_count?: number;
status: 'active' | 'paused' | 'completed';
date_created?: string;
}
export interface HeadlineInventory {
id: string;
campaign: string | CampaignMaster;
final_title_text: string;
status: 'available' | 'used';
used_on_article?: string;
date_created?: string;
}
export interface ContentFragment {
id: string;
campaign: string | CampaignMaster;
fragment_type: FragmentType;
content_body: string;
word_count?: number;
date_created?: string;
}
export type FragmentType =
| 'intro_hook'
| 'pillar_1_keyword'
| 'pillar_2_uniqueness'
| 'pillar_3_relevance'
| 'pillar_4_quality'
| 'pillar_5_authority'
| 'pillar_6_backlinks'
| 'faq_section';
export interface GeneratedArticle {
id: string;
site: string | Site;
campaign?: string | CampaignMaster;
headline: string;
meta_title: string;
meta_description: string;
full_html_body: string;
word_count: number;
is_published: boolean;
featured_image?: string;
location_state?: string;
location_county?: string;
location_city?: string;
date_created?: string;
date_updated?: string;
}
export interface ImageTemplate {
id: string;
site?: string | Site;
name: string;
svg_source: string;
preview?: string;
is_default: boolean;
date_created?: string;
}
// Location Types
export interface LocationState {
id: string;
name: string;
code: string;
country_code: string;
}
export interface LocationCounty {
id: string;
name: string;
state: string | LocationState;
fips_code?: string;
population?: number;
}
export interface LocationCity {
id: string;
name: string;
county: string | LocationCounty;
state: string | LocationState;
lat?: number;
lng?: number;
population?: number;
postal_code?: string;
ranking?: number;
}
// Lead Capture Types
export interface Lead {
id: string;
site: string | Site;
name: string;
email: string;
phone?: string;
message?: string;
source?: string;
date_created?: string;
}
export interface NewsletterSubscriber {
id: string;
site: string | Site;
email: string;
status: 'subscribed' | 'unsubscribed';
date_created?: string;
}
// Form Builder Types
export interface Form {
id: string;
site: string | Site;
name: string;
fields: FormField[];
submit_action: 'email' | 'webhook' | 'both';
submit_email?: string;
submit_webhook?: string;
success_message?: string;
redirect_url?: string;
}
export interface FormField {
name: string;
label: string;
type: 'text' | 'email' | 'phone' | 'textarea' | 'select' | 'checkbox';
required: boolean;
options?: string[];
placeholder?: string;
}
export interface FormSubmission {
id: string;
form: string | Form;
site: string | Site;
data: Record<string, any>;
date_created?: string;
}
/**
* Full Spark Platform Schema for Directus SDK
*/
export interface SparkSchema {
sites: Site[];
pages: Page[];
posts: Post[];
globals: Globals[];
navigation: Navigation[];
authors: Author[];
campaign_masters: CampaignMaster[];
headline_inventory: HeadlineInventory[];
content_fragments: ContentFragment[];
generated_articles: GeneratedArticle[];
image_templates: ImageTemplate[];
locations_states: LocationState[];
locations_counties: LocationCounty[];
locations_cities: LocationCity[];
leads: Lead[];
newsletter_subscribers: NewsletterSubscriber[];
forms: Form[];
form_submissions: FormSubmission[];
}

View File

@@ -0,0 +1,82 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: [
'./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
shimmer: {
"100%": { transform: "translateX(100%)" },
},
pulse: {
"0%, 100%": { opacity: "1" },
"50%": { opacity: "0.5" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
shimmer: "shimmer 2s infinite",
pulse: "pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite",
},
},
},
plugins: [require("tailwindcss-animate")],
}

22
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
],
"@/components/*": [
"src/components/*"
],
"@/lib/*": [
"src/lib/*"
],
"@/types/*": [
"src/types/*"
]
},
"jsx": "react-jsx",
"jsxImportSource": "react"
}
}