Initial commit: Spark Platform with Cartesian SEO Engine
This commit is contained in:
32
.env.example
Normal file
32
.env.example
Normal 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
31
.gitignore
vendored
Normal 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
234
README.md
Normal 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
26
directus/Dockerfile
Normal 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
|
||||||
2
directus/extensions/.gitkeep
Normal file
2
directus/extensions/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Placeholder for Directus extensions
|
||||||
|
# Custom extensions go here
|
||||||
14
directus/package.json
Normal file
14
directus/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
79
directus/scripts/import_template.js
Normal file
79
directus/scripts/import_template.js
Normal 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);
|
||||||
179
directus/scripts/load_locations.js
Normal file
179
directus/scripts/load_locations.js
Normal 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);
|
||||||
243
directus/template/src/collections.json
Normal file
243
directus/template/src/collections.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
1349
directus/template/src/fields.json
Normal file
1349
directus/template/src/fields.json
Normal file
File diff suppressed because it is too large
Load Diff
189
directus/template/src/relations.json
Normal file
189
directus/template/src/relations.json
Normal 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
115
docker-compose.yaml
Normal 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
28
frontend/Dockerfile
Normal 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
27
frontend/astro.config.ts
Normal 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
44
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.mjs
Normal file
6
frontend/postcss.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
10
frontend/public/favicon.svg
Normal file
10
frontend/public/favicon.svg
Normal 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 |
203
frontend/src/components/admin/ArticleGenerator.tsx
Normal file
203
frontend/src/components/admin/ArticleGenerator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
311
frontend/src/components/admin/CampaignManager.tsx
Normal file
311
frontend/src/components/admin/CampaignManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
232
frontend/src/components/admin/ImageTemplateEditor.tsx
Normal file
232
frontend/src/components/admin/ImageTemplateEditor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
250
frontend/src/components/admin/LocationBrowser.tsx
Normal file
250
frontend/src/components/admin/LocationBrowser.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
frontend/src/components/blocks/BlockColumns.astro
Normal file
56
frontend/src/components/blocks/BlockColumns.astro
Normal 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>
|
||||||
72
frontend/src/components/blocks/BlockFAQ.astro
Normal file
72
frontend/src/components/blocks/BlockFAQ.astro
Normal 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>
|
||||||
165
frontend/src/components/blocks/BlockForm.astro
Normal file
165
frontend/src/components/blocks/BlockForm.astro
Normal 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>
|
||||||
62
frontend/src/components/blocks/BlockGallery.astro
Normal file
62
frontend/src/components/blocks/BlockGallery.astro
Normal 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>
|
||||||
78
frontend/src/components/blocks/BlockHero.astro
Normal file
78
frontend/src/components/blocks/BlockHero.astro
Normal 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>
|
||||||
51
frontend/src/components/blocks/BlockMedia.astro
Normal file
51
frontend/src/components/blocks/BlockMedia.astro
Normal 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>
|
||||||
207
frontend/src/components/blocks/BlockPosts.astro
Normal file
207
frontend/src/components/blocks/BlockPosts.astro
Normal 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>
|
||||||
86
frontend/src/components/blocks/BlockQuote.astro
Normal file
86
frontend/src/components/blocks/BlockQuote.astro
Normal 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>
|
||||||
|
)}
|
||||||
35
frontend/src/components/blocks/BlockRichText.astro
Normal file
35
frontend/src/components/blocks/BlockRichText.astro
Normal 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>
|
||||||
86
frontend/src/components/blocks/BlockSteps.astro
Normal file
86
frontend/src/components/blocks/BlockSteps.astro
Normal 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>
|
||||||
39
frontend/src/components/ui/badge.tsx
Normal file
39
frontend/src/components/ui/badge.tsx
Normal 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 }
|
||||||
55
frontend/src/components/ui/button.tsx
Normal file
55
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
|
outline:
|
||||||
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-10 px-4 py-2",
|
||||||
|
sm: "h-9 rounded-md px-3",
|
||||||
|
lg: "h-11 rounded-md px-8",
|
||||||
|
icon: "h-10 w-10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : "button"
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = "Button"
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
78
frontend/src/components/ui/card.tsx
Normal file
78
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Card = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Card.displayName = "Card"
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardHeader.displayName = "CardHeader"
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLHeadingElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<h3
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-2xl font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardDescription.displayName = "CardDescription"
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
|
))
|
||||||
|
CardContent.displayName = "CardContent"
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
CardFooter.displayName = "CardFooter"
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||||
116
frontend/src/components/ui/dialog.tsx
Normal file
116
frontend/src/components/ui/dialog.tsx
Normal 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,
|
||||||
|
}
|
||||||
38
frontend/src/components/ui/input.tsx
Normal file
38
frontend/src/components/ui/input.tsx
Normal 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 }
|
||||||
22
frontend/src/components/ui/spinner.tsx
Normal file
22
frontend/src/components/ui/spinner.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Spinner({ className, size = "default" }: { className?: string; size?: "sm" | "default" | "lg" }) {
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: "h-4 w-4",
|
||||||
|
default: "h-8 w-8",
|
||||||
|
lg: "h-12 w-12"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center justify-center", className)}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"animate-spin rounded-full border-2 border-current border-t-transparent text-primary",
|
||||||
|
sizeClasses[size]
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Spinner }
|
||||||
52
frontend/src/components/ui/tabs.tsx
Normal file
52
frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
37
frontend/src/components/ui/textarea.tsx
Normal file
37
frontend/src/components/ui/textarea.tsx
Normal 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
21
frontend/src/env.d.ts
vendored
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
201
frontend/src/layouts/AdminLayout.astro
Normal file
201
frontend/src/layouts/AdminLayout.astro
Normal 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>
|
||||||
271
frontend/src/layouts/BaseLayout.astro
Normal file
271
frontend/src/layouts/BaseLayout.astro
Normal 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>
|
||||||
50
frontend/src/lib/directus/client.ts
Normal file
50
frontend/src/lib/directus/client.ts
Normal 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
|
||||||
|
};
|
||||||
278
frontend/src/lib/directus/fetchers.ts
Normal file
278
frontend/src/lib/directus/fetchers.ts
Normal 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']
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
361
frontend/src/lib/seo/cartesian.ts
Normal file
361
frontend/src/lib/seo/cartesian.ts
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
/**
|
||||||
|
* Spark Platform - Cartesian Permutation Engine
|
||||||
|
*
|
||||||
|
* Implements true Cartesian Product logic for spintax explosion:
|
||||||
|
* - n^k formula for total combinations
|
||||||
|
* - Location × Spintax cross-product
|
||||||
|
* - Iterator-based generation for memory efficiency
|
||||||
|
*
|
||||||
|
* The Cartesian Product generates ALL possible combinations where:
|
||||||
|
* - Every element of Set A combines with every element of Set B, C, etc.
|
||||||
|
* - Order matters: (A,B) ≠ (B,A)
|
||||||
|
* - Formula: n₁ × n₂ × n₃ × ... × nₖ
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* Spintax: "{Best|Top} {Dentist|Clinic} in {city}"
|
||||||
|
* Cities: ["Austin", "Dallas"]
|
||||||
|
* Result: 2 × 2 × 2 = 8 unique headlines
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
SpintaxSlot,
|
||||||
|
CartesianConfig,
|
||||||
|
CartesianResult,
|
||||||
|
CartesianMetadata,
|
||||||
|
LocationEntry,
|
||||||
|
VariableMap,
|
||||||
|
DEFAULT_CARTESIAN_CONFIG
|
||||||
|
} from '@/types/cartesian';
|
||||||
|
|
||||||
|
// Re-export the default config
|
||||||
|
export { DEFAULT_CARTESIAN_CONFIG } from '@/types/cartesian';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract all spintax slots from a template string
|
||||||
|
* Handles nested spintax by processing innermost first
|
||||||
|
*
|
||||||
|
* @param text - The template string with {option1|option2} syntax
|
||||||
|
* @returns Array of SpintaxSlot objects
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* extractSpintaxSlots("{Best|Top} dentist")
|
||||||
|
* // Returns: [{ original: "{Best|Top}", options: ["Best", "Top"], position: 0, startIndex: 0, endIndex: 10 }]
|
||||||
|
*/
|
||||||
|
export function extractSpintaxSlots(text: string): SpintaxSlot[] {
|
||||||
|
const slots: SpintaxSlot[] = [];
|
||||||
|
// Match innermost braces only (no nested braces inside)
|
||||||
|
const pattern = /\{([^{}]+)\}/g;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
let position = 0;
|
||||||
|
|
||||||
|
while ((match = pattern.exec(text)) !== null) {
|
||||||
|
// Only treat as spintax if it contains pipe separator
|
||||||
|
if (match[1].includes('|')) {
|
||||||
|
slots.push({
|
||||||
|
original: match[0],
|
||||||
|
options: match[1].split('|').map(s => s.trim()),
|
||||||
|
position: position++,
|
||||||
|
startIndex: match.index,
|
||||||
|
endIndex: match.index + match[0].length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate total combinations using the n^k (Cartesian product) formula
|
||||||
|
*
|
||||||
|
* For k slots with n₁, n₂, ..., nₖ options respectively:
|
||||||
|
* Total = n₁ × n₂ × n₃ × ... × nₖ
|
||||||
|
*
|
||||||
|
* @param slots - Array of spintax slots
|
||||||
|
* @param locationCount - Number of locations to cross with (default 1)
|
||||||
|
* @returns Total number of possible combinations, capped at safe integer max
|
||||||
|
*/
|
||||||
|
export function calculateTotalCombinations(
|
||||||
|
slots: SpintaxSlot[],
|
||||||
|
locationCount: number = 1
|
||||||
|
): number {
|
||||||
|
if (slots.length === 0 && locationCount <= 1) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let total = Math.max(locationCount, 1);
|
||||||
|
|
||||||
|
for (const slot of slots) {
|
||||||
|
total *= slot.options.length;
|
||||||
|
// Safety check to prevent overflow
|
||||||
|
if (total > Number.MAX_SAFE_INTEGER) {
|
||||||
|
return Number.MAX_SAFE_INTEGER;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate all Cartesian product combinations from spintax slots
|
||||||
|
* Uses an iterative approach with index-based selection for memory efficiency
|
||||||
|
*
|
||||||
|
* The algorithm works like a "combination lock" or odometer:
|
||||||
|
* - Each slot is a dial with n options
|
||||||
|
* - We count through all n₁ × n₂ × ... × nₖ combinations
|
||||||
|
* - The index maps to specific choices via modular arithmetic
|
||||||
|
*
|
||||||
|
* @param template - Original template string
|
||||||
|
* @param slots - Extracted spintax slots
|
||||||
|
* @param config - Generation configuration
|
||||||
|
* @yields CartesianResult for each combination
|
||||||
|
*/
|
||||||
|
export function* generateCartesianProduct(
|
||||||
|
template: string,
|
||||||
|
slots: SpintaxSlot[],
|
||||||
|
config: Partial<CartesianConfig> = {}
|
||||||
|
): Generator<CartesianResult> {
|
||||||
|
const { maxCombinations = 10000, offset = 0 } = config;
|
||||||
|
|
||||||
|
if (slots.length === 0) {
|
||||||
|
yield {
|
||||||
|
text: template,
|
||||||
|
slotValues: {},
|
||||||
|
index: 0
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCombinations = calculateTotalCombinations(slots);
|
||||||
|
const limit = Math.min(totalCombinations, maxCombinations);
|
||||||
|
const startIndex = Math.min(offset, totalCombinations);
|
||||||
|
|
||||||
|
// Pre-calculate divisors for index-to-options mapping
|
||||||
|
const divisors: number[] = [];
|
||||||
|
let divisor = 1;
|
||||||
|
for (let i = slots.length - 1; i >= 0; i--) {
|
||||||
|
divisors[i] = divisor;
|
||||||
|
divisor *= slots[i].options.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate combinations using index-based selection
|
||||||
|
for (let index = startIndex; index < Math.min(startIndex + limit, totalCombinations); index++) {
|
||||||
|
let result = template;
|
||||||
|
const slotValues: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Map index to specific option choices (like reading an odometer)
|
||||||
|
for (let i = 0; i < slots.length; i++) {
|
||||||
|
const slot = slots[i];
|
||||||
|
const optionIndex = Math.floor(index / divisors[i]) % slot.options.length;
|
||||||
|
const chosenOption = slot.options[optionIndex];
|
||||||
|
|
||||||
|
slotValues[`slot_${i}`] = chosenOption;
|
||||||
|
result = result.replace(slot.original, chosenOption);
|
||||||
|
}
|
||||||
|
|
||||||
|
yield {
|
||||||
|
text: result,
|
||||||
|
slotValues,
|
||||||
|
index
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate full Cartesian product including location cross-product
|
||||||
|
*
|
||||||
|
* This creates the FULL cross-product:
|
||||||
|
* (Spintax combinations) × (Location variations)
|
||||||
|
*
|
||||||
|
* @param template - The spintax template
|
||||||
|
* @param locations - Array of location entries to cross with
|
||||||
|
* @param nicheVariables - Additional variables to inject
|
||||||
|
* @param config - Generation configuration
|
||||||
|
* @yields CartesianResult with location data
|
||||||
|
*/
|
||||||
|
export function* generateWithLocations(
|
||||||
|
template: string,
|
||||||
|
locations: LocationEntry[],
|
||||||
|
nicheVariables: VariableMap = {},
|
||||||
|
config: Partial<CartesianConfig> = {}
|
||||||
|
): Generator<CartesianResult> {
|
||||||
|
const { maxCombinations = 10000 } = config;
|
||||||
|
|
||||||
|
const slots = extractSpintaxSlots(template);
|
||||||
|
const spintaxCombinations = calculateTotalCombinations(slots);
|
||||||
|
const locationCount = Math.max(locations.length, 1);
|
||||||
|
const totalCombinations = spintaxCombinations * locationCount;
|
||||||
|
|
||||||
|
let generated = 0;
|
||||||
|
|
||||||
|
// If no locations, just generate spintax variations
|
||||||
|
if (locations.length === 0) {
|
||||||
|
for (const result of generateCartesianProduct(template, slots, config)) {
|
||||||
|
if (generated >= maxCombinations) return;
|
||||||
|
|
||||||
|
// Inject niche variables
|
||||||
|
const text = injectVariables(result.text, nicheVariables);
|
||||||
|
|
||||||
|
yield {
|
||||||
|
...result,
|
||||||
|
text,
|
||||||
|
index: generated++
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full cross-product: spintax × locations
|
||||||
|
for (const location of locations) {
|
||||||
|
// Build location variables
|
||||||
|
const locationVars: VariableMap = {
|
||||||
|
city: location.city || '',
|
||||||
|
county: location.county || '',
|
||||||
|
state: location.state,
|
||||||
|
state_code: location.stateCode,
|
||||||
|
population: String(location.population || '')
|
||||||
|
};
|
||||||
|
|
||||||
|
// Merge with niche variables
|
||||||
|
const allVariables = { ...nicheVariables, ...locationVars };
|
||||||
|
|
||||||
|
// Generate all spintax combinations for this location
|
||||||
|
for (const result of generateCartesianProduct(template, slots, { maxCombinations: Infinity })) {
|
||||||
|
if (generated >= maxCombinations) return;
|
||||||
|
|
||||||
|
// Inject all variables
|
||||||
|
const text = injectVariables(result.text, allVariables);
|
||||||
|
|
||||||
|
yield {
|
||||||
|
text,
|
||||||
|
slotValues: result.slotValues,
|
||||||
|
location: {
|
||||||
|
city: location.city,
|
||||||
|
county: location.county,
|
||||||
|
state: location.state,
|
||||||
|
stateCode: location.stateCode,
|
||||||
|
id: location.id
|
||||||
|
},
|
||||||
|
index: generated++
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject variables into text, replacing {varName} placeholders
|
||||||
|
* Unlike spintax, variable placeholders don't contain pipe separators
|
||||||
|
*
|
||||||
|
* @param text - Text with {variable} placeholders
|
||||||
|
* @param variables - Map of variable names to values
|
||||||
|
* @returns Text with variables replaced
|
||||||
|
*/
|
||||||
|
export function injectVariables(text: string, variables: VariableMap): string {
|
||||||
|
let result = text;
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(variables)) {
|
||||||
|
// Match {key} but NOT {key|other} (that's spintax)
|
||||||
|
const pattern = new RegExp(`\\{${key}\\}`, 'gi');
|
||||||
|
result = result.replace(pattern, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse spintax and randomly select ONE variation (for content fragments)
|
||||||
|
* This is different from Cartesian explosion - it picks a single random path
|
||||||
|
*
|
||||||
|
* @param text - Text with spintax {option1|option2}
|
||||||
|
* @returns Single randomly selected variation
|
||||||
|
*/
|
||||||
|
export function parseSpintaxRandom(text: string): string {
|
||||||
|
const pattern = /\{([^{}]+)\}/g;
|
||||||
|
|
||||||
|
function processMatch(_match: string, group: string): string {
|
||||||
|
if (!group.includes('|')) {
|
||||||
|
return `{${group}}`; // Not spintax, preserve as variable placeholder
|
||||||
|
}
|
||||||
|
const options = group.split('|');
|
||||||
|
return options[Math.floor(Math.random() * options.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
let result = text;
|
||||||
|
let previousResult = '';
|
||||||
|
|
||||||
|
// Process nested spintax (innermost first)
|
||||||
|
while (result !== previousResult) {
|
||||||
|
previousResult = result;
|
||||||
|
result = result.replace(pattern, processMatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explode spintax into ALL variations without locations
|
||||||
|
* Convenience function for simple use cases
|
||||||
|
*
|
||||||
|
* @param text - Spintax template
|
||||||
|
* @param maxCount - Maximum results
|
||||||
|
* @returns Array of all variations
|
||||||
|
*/
|
||||||
|
export function explodeSpintax(text: string, maxCount = 5000): string[] {
|
||||||
|
const slots = extractSpintaxSlots(text);
|
||||||
|
const results: string[] = [];
|
||||||
|
|
||||||
|
for (const result of generateCartesianProduct(text, slots, { maxCombinations: maxCount })) {
|
||||||
|
results.push(result.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get metadata about a Cartesian product without running generation
|
||||||
|
* Useful for UI to show "This will generate X combinations"
|
||||||
|
*
|
||||||
|
* @param template - Spintax template
|
||||||
|
* @param locationCount - Number of locations
|
||||||
|
* @param maxCombinations - Generation limit
|
||||||
|
* @returns Metadata object
|
||||||
|
*/
|
||||||
|
export function getCartesianMetadata(
|
||||||
|
template: string,
|
||||||
|
locationCount: number = 1,
|
||||||
|
maxCombinations: number = 10000
|
||||||
|
): CartesianMetadata {
|
||||||
|
const slots = extractSpintaxSlots(template);
|
||||||
|
const totalSpintaxCombinations = calculateTotalCombinations(slots);
|
||||||
|
const totalPossibleCombinations = totalSpintaxCombinations * Math.max(locationCount, 1);
|
||||||
|
const generatedCount = Math.min(totalPossibleCombinations, maxCombinations);
|
||||||
|
|
||||||
|
return {
|
||||||
|
template,
|
||||||
|
slotCount: slots.length,
|
||||||
|
totalSpintaxCombinations,
|
||||||
|
locationCount,
|
||||||
|
totalPossibleCombinations,
|
||||||
|
generatedCount,
|
||||||
|
wasTruncated: totalPossibleCombinations > maxCombinations
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect results from a generator into an array
|
||||||
|
* Helper for when you need all results at once
|
||||||
|
*/
|
||||||
|
export function collectResults(
|
||||||
|
generator: Generator<CartesianResult>,
|
||||||
|
limit?: number
|
||||||
|
): CartesianResult[] {
|
||||||
|
const results: CartesianResult[] = [];
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
for (const result of generator) {
|
||||||
|
results.push(result);
|
||||||
|
count++;
|
||||||
|
if (limit && count >= limit) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal 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))
|
||||||
|
}
|
||||||
57
frontend/src/middleware.ts
Normal file
57
frontend/src/middleware.ts
Normal 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();
|
||||||
|
});
|
||||||
58
frontend/src/pages/[...slug].astro
Normal file
58
frontend/src/pages/[...slug].astro
Normal 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>
|
||||||
148
frontend/src/pages/admin/index.astro
Normal file
148
frontend/src/pages/admin/index.astro
Normal 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>
|
||||||
8
frontend/src/pages/admin/locations.astro
Normal file
8
frontend/src/pages/admin/locations.astro
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
import AdminLayout from '../../../layouts/AdminLayout.astro';
|
||||||
|
import LocationBrowser from '../../../components/admin/LocationBrowser';
|
||||||
|
---
|
||||||
|
|
||||||
|
<AdminLayout title="Locations">
|
||||||
|
<LocationBrowser client:load />
|
||||||
|
</AdminLayout>
|
||||||
8
frontend/src/pages/admin/media/templates.astro
Normal file
8
frontend/src/pages/admin/media/templates.astro
Normal 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>
|
||||||
8
frontend/src/pages/admin/seo/articles.astro
Normal file
8
frontend/src/pages/admin/seo/articles.astro
Normal 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>
|
||||||
8
frontend/src/pages/admin/seo/campaigns.astro
Normal file
8
frontend/src/pages/admin/seo/campaigns.astro
Normal 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>
|
||||||
81
frontend/src/pages/api/campaigns.ts
Normal file
81
frontend/src/pages/api/campaigns.ts
Normal 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' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
40
frontend/src/pages/api/lead.ts
Normal file
40
frontend/src/pages/api/lead.ts
Normal 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' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
37
frontend/src/pages/api/locations/cities.ts
Normal file
37
frontend/src/pages/api/locations/cities.ts
Normal 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' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
36
frontend/src/pages/api/locations/counties.ts
Normal file
36
frontend/src/pages/api/locations/counties.ts
Normal 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' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
26
frontend/src/pages/api/locations/states.ts
Normal file
26
frontend/src/pages/api/locations/states.ts
Normal 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' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
72
frontend/src/pages/api/media/templates.ts
Normal file
72
frontend/src/pages/api/media/templates.ts
Normal 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' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
43
frontend/src/pages/api/seo/articles.ts
Normal file
43
frontend/src/pages/api/seo/articles.ts
Normal 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' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
268
frontend/src/pages/api/seo/generate-article.ts
Normal file
268
frontend/src/pages/api/seo/generate-article.ts
Normal 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' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
380
frontend/src/pages/api/seo/generate-headlines.ts
Normal file
380
frontend/src/pages/api/seo/generate-headlines.ts
Normal 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' } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
75
frontend/src/pages/index.astro
Normal file
75
frontend/src/pages/index.astro
Normal 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>
|
||||||
3
frontend/src/styles/globals.css
Normal file
3
frontend/src/styles/globals.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
109
frontend/src/types/cartesian.ts
Normal file
109
frontend/src/types/cartesian.ts
Normal 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>;
|
||||||
264
frontend/src/types/schema.ts
Normal file
264
frontend/src/types/schema.ts
Normal 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[];
|
||||||
|
}
|
||||||
82
frontend/tailwind.config.mjs
Normal file
82
frontend/tailwind.config.mjs
Normal 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
22
frontend/tsconfig.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user