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