commit abd964a745180aef32240170821b478126f95fef Author: cawcenter Date: Thu Dec 11 23:21:35 2025 -0500 Initial commit: Spark Platform with Cartesian SEO Engine diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5a53c07 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..71f10ee --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ebc9a6 --- /dev/null +++ b/README.md @@ -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 +

Looking for the {best|top|leading} {service} in {city}? +{Our team|We} {specialize in|focus on} {providing|delivering} +{exceptional|outstanding} {results|outcomes}.

+``` + +### 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. diff --git a/directus/Dockerfile b/directus/Dockerfile new file mode 100644 index 0000000..a930d7f --- /dev/null +++ b/directus/Dockerfile @@ -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 diff --git a/directus/extensions/.gitkeep b/directus/extensions/.gitkeep new file mode 100644 index 0000000..f51d096 --- /dev/null +++ b/directus/extensions/.gitkeep @@ -0,0 +1,2 @@ +# Placeholder for Directus extensions +# Custom extensions go here diff --git a/directus/package.json b/directus/package.json new file mode 100644 index 0000000..d8e3715 --- /dev/null +++ b/directus/package.json @@ -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" + } +} \ No newline at end of file diff --git a/directus/scripts/import_template.js b/directus/scripts/import_template.js new file mode 100644 index 0000000..2ea18a0 --- /dev/null +++ b/directus/scripts/import_template.js @@ -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); diff --git a/directus/scripts/load_locations.js b/directus/scripts/load_locations.js new file mode 100644 index 0000000..b087a5d --- /dev/null +++ b/directus/scripts/load_locations.js @@ -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); diff --git a/directus/template/src/collections.json b/directus/template/src/collections.json new file mode 100644 index 0000000..cb5f4ac --- /dev/null +++ b/directus/template/src/collections.json @@ -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" + } + } +] \ No newline at end of file diff --git a/directus/template/src/fields.json b/directus/template/src/fields.json new file mode 100644 index 0000000..83219d7 --- /dev/null +++ b/directus/template/src/fields.json @@ -0,0 +1,1349 @@ +{ + "sites": [ + { + "field": "id", + "type": "uuid", + "meta": { + "special": [ + "uuid" + ], + "interface": "input", + "readonly": true, + "hidden": true + } + }, + { + "field": "status", + "type": "string", + "meta": { + "interface": "select-dropdown", + "options": { + "choices": [ + { + "text": "Active", + "value": "active" + }, + { + "text": "Inactive", + "value": "inactive" + } + ] + }, + "width": "half" + }, + "schema": { + "default_value": "active" + } + }, + { + "field": "name", + "type": "string", + "meta": { + "interface": "input", + "width": "half", + "required": true + } + }, + { + "field": "domain", + "type": "string", + "meta": { + "interface": "input", + "width": "half", + "required": true, + "note": "Primary domain for this site" + } + }, + { + "field": "domain_aliases", + "type": "json", + "meta": { + "interface": "tags", + "width": "half", + "note": "Alternative domains that resolve to this site" + } + }, + { + "field": "settings", + "type": "json", + "meta": { + "interface": "input-code", + "options": { + "language": "json" + } + } + }, + { + "field": "date_created", + "type": "timestamp", + "meta": { + "special": [ + "date-created" + ], + "interface": "datetime", + "readonly": true, + "hidden": true + } + }, + { + "field": "date_updated", + "type": "timestamp", + "meta": { + "special": [ + "date-updated" + ], + "interface": "datetime", + "readonly": true, + "hidden": true + } + } + ], + "pages": [ + { + "field": "id", + "type": "uuid", + "meta": { + "special": [ + "uuid" + ], + "interface": "input", + "readonly": true, + "hidden": true + } + }, + { + "field": "status", + "type": "string", + "meta": { + "interface": "select-dropdown", + "options": { + "choices": [ + { + "text": "Draft", + "value": "draft" + }, + { + "text": "Published", + "value": "published" + }, + { + "text": "Archived", + "value": "archived" + } + ] + }, + "width": "half" + }, + "schema": { + "default_value": "draft" + } + }, + { + "field": "site", + "type": "uuid", + "meta": { + "interface": "select-dropdown-m2o", + "special": [ + "m2o" + ], + "required": true, + "width": "half" + } + }, + { + "field": "title", + "type": "string", + "meta": { + "interface": "input", + "required": true + } + }, + { + "field": "permalink", + "type": "string", + "meta": { + "interface": "input", + "required": true, + "note": "URL path like /about or /services/dental" + } + }, + { + "field": "seo_title", + "type": "string", + "meta": { + "interface": "input", + "note": "Override page title for SEO (max 70 chars)" + } + }, + { + "field": "seo_description", + "type": "text", + "meta": { + "interface": "input-multiline", + "note": "Meta description (max 160 chars)" + } + }, + { + "field": "seo_image", + "type": "uuid", + "meta": { + "interface": "file-image", + "special": [ + "file" + ] + } + }, + { + "field": "blocks", + "type": "alias", + "meta": { + "interface": "list-m2a", + "special": [ + "m2a" + ], + "note": "Page content blocks" + } + }, + { + "field": "date_created", + "type": "timestamp", + "meta": { + "special": [ + "date-created" + ], + "interface": "datetime", + "readonly": true, + "hidden": true + } + }, + { + "field": "date_updated", + "type": "timestamp", + "meta": { + "special": [ + "date-updated" + ], + "interface": "datetime", + "readonly": true, + "hidden": true + } + } + ], + "posts": [ + { + "field": "id", + "type": "uuid", + "meta": { + "special": [ + "uuid" + ], + "interface": "input", + "readonly": true, + "hidden": true + } + }, + { + "field": "status", + "type": "string", + "meta": { + "interface": "select-dropdown", + "options": { + "choices": [ + { + "text": "Draft", + "value": "draft" + }, + { + "text": "Published", + "value": "published" + }, + { + "text": "Archived", + "value": "archived" + } + ] + }, + "width": "half" + }, + "schema": { + "default_value": "draft" + } + }, + { + "field": "site", + "type": "uuid", + "meta": { + "interface": "select-dropdown-m2o", + "special": [ + "m2o" + ], + "required": true, + "width": "half" + } + }, + { + "field": "title", + "type": "string", + "meta": { + "interface": "input", + "required": true + } + }, + { + "field": "slug", + "type": "string", + "meta": { + "interface": "input", + "required": true + } + }, + { + "field": "excerpt", + "type": "text", + "meta": { + "interface": "input-multiline" + } + }, + { + "field": "content", + "type": "text", + "meta": { + "interface": "input-rich-text-html" + } + }, + { + "field": "featured_image", + "type": "uuid", + "meta": { + "interface": "file-image", + "special": [ + "file" + ] + } + }, + { + "field": "category", + "type": "string", + "meta": { + "interface": "input" + } + }, + { + "field": "author", + "type": "uuid", + "meta": { + "interface": "select-dropdown-m2o", + "special": [ + "m2o" + ] + } + }, + { + "field": "published_at", + "type": "timestamp", + "meta": { + "interface": "datetime" + } + }, + { + "field": "seo_title", + "type": "string", + "meta": { + "interface": "input" + } + }, + { + "field": "seo_description", + "type": "text", + "meta": { + "interface": "input-multiline" + } + }, + { + "field": "date_created", + "type": "timestamp", + "meta": { + "special": [ + "date-created" + ], + "interface": "datetime", + "readonly": true, + "hidden": true + } + }, + { + "field": "date_updated", + "type": "timestamp", + "meta": { + "special": [ + "date-updated" + ], + "interface": "datetime", + "readonly": true, + "hidden": true + } + } + ], + "globals": [ + { + "field": "id", + "type": "uuid", + "meta": { + "special": [ + "uuid" + ], + "interface": "input", + "readonly": true, + "hidden": true + } + }, + { + "field": "site", + "type": "uuid", + "meta": { + "interface": "select-dropdown-m2o", + "special": [ + "m2o" + ], + "required": true + } + }, + { + "field": "site_name", + "type": "string", + "meta": { + "interface": "input" + } + }, + { + "field": "site_tagline", + "type": "string", + "meta": { + "interface": "input" + } + }, + { + "field": "logo", + "type": "uuid", + "meta": { + "interface": "file-image", + "special": [ + "file" + ] + } + }, + { + "field": "favicon", + "type": "uuid", + "meta": { + "interface": "file-image", + "special": [ + "file" + ] + } + }, + { + "field": "primary_color", + "type": "string", + "meta": { + "interface": "select-color" + } + }, + { + "field": "secondary_color", + "type": "string", + "meta": { + "interface": "select-color" + } + }, + { + "field": "footer_text", + "type": "text", + "meta": { + "interface": "input-multiline" + } + }, + { + "field": "social_links", + "type": "json", + "meta": { + "interface": "list", + "options": { + "template": "{{platform}}: {{url}}" + } + } + }, + { + "field": "scripts_head", + "type": "text", + "meta": { + "interface": "input-code", + "options": { + "language": "html" + } + } + }, + { + "field": "scripts_body", + "type": "text", + "meta": { + "interface": "input-code", + "options": { + "language": "html" + } + } + } + ], + "navigation": [ + { + "field": "id", + "type": "uuid", + "meta": { + "special": [ + "uuid" + ], + "interface": "input", + "readonly": true, + "hidden": true + } + }, + { + "field": "site", + "type": "uuid", + "meta": { + "interface": "select-dropdown-m2o", + "special": [ + "m2o" + ], + "required": true + } + }, + { + "field": "label", + "type": "string", + "meta": { + "interface": "input", + "required": true + } + }, + { + "field": "url", + "type": "string", + "meta": { + "interface": "input", + "required": true + } + }, + { + "field": "target", + "type": "string", + "meta": { + "interface": "select-dropdown", + "options": { + "choices": [ + { + "text": "Same Window", + "value": "_self" + }, + { + "text": "New Window", + "value": "_blank" + } + ] + } + }, + "schema": { + "default_value": "_self" + } + }, + { + "field": "parent", + "type": "uuid", + "meta": { + "interface": "select-dropdown-m2o", + "special": [ + "m2o" + ] + } + }, + { + "field": "sort", + "type": "integer", + "meta": { + "interface": "input", + "special": [ + "sort" + ] + } + } + ], + "campaign_masters": [ + { + "field": "id", + "type": "uuid", + "meta": { + "special": [ + "uuid" + ], + "interface": "input", + "readonly": true, + "hidden": true + } + }, + { + "field": "status", + "type": "string", + "meta": { + "interface": "select-dropdown", + "options": { + "choices": [ + { + "text": "Active", + "value": "active" + }, + { + "text": "Paused", + "value": "paused" + }, + { + "text": "Completed", + "value": "completed" + } + ] + }, + "width": "half" + }, + "schema": { + "default_value": "active" + } + }, + { + "field": "site", + "type": "uuid", + "meta": { + "interface": "select-dropdown-m2o", + "special": [ + "m2o" + ], + "width": "half", + "note": "Leave empty for global campaign" + } + }, + { + "field": "name", + "type": "string", + "meta": { + "interface": "input", + "required": true + } + }, + { + "field": "headline_spintax_root", + "type": "text", + "meta": { + "interface": "input-multiline", + "required": true, + "note": "Use {option1|option2} syntax" + } + }, + { + "field": "niche_variables", + "type": "json", + "meta": { + "interface": "input-code", + "options": { + "language": "json" + }, + "note": "Variables like {target}, {service}" + } + }, + { + "field": "location_mode", + "type": "string", + "meta": { + "interface": "select-dropdown", + "options": { + "choices": [ + { + "text": "None", + "value": "none" + }, + { + "text": "State", + "value": "state" + }, + { + "text": "County", + "value": "county" + }, + { + "text": "City", + "value": "city" + } + ] + } + }, + "schema": { + "default_value": "none" + } + }, + { + "field": "location_target", + "type": "uuid", + "meta": { + "interface": "select-dropdown-m2o", + "special": [ + "m2o" + ], + "note": "Target state/county/city" + } + }, + { + "field": "batch_count", + "type": "integer", + "meta": { + "interface": "input", + "note": "Number of articles to generate per batch" + }, + "schema": { + "default_value": 1 + } + }, + { + "field": "date_created", + "type": "timestamp", + "meta": { + "special": [ + "date-created" + ], + "interface": "datetime", + "readonly": true + } + } + ], + "headline_inventory": [ + { + "field": "id", + "type": "uuid", + "meta": { + "special": [ + "uuid" + ], + "interface": "input", + "readonly": true, + "hidden": true + } + }, + { + "field": "campaign", + "type": "uuid", + "meta": { + "interface": "select-dropdown-m2o", + "special": [ + "m2o" + ], + "required": true + } + }, + { + "field": "final_title_text", + "type": "string", + "meta": { + "interface": "input", + "required": true + } + }, + { + "field": "status", + "type": "string", + "meta": { + "interface": "select-dropdown", + "options": { + "choices": [ + { + "text": "Available", + "value": "available" + }, + { + "text": "Used", + "value": "used" + } + ] + } + }, + "schema": { + "default_value": "available" + } + }, + { + "field": "used_on_article", + "type": "uuid", + "meta": { + "interface": "select-dropdown-m2o", + "special": [ + "m2o" + ] + } + }, + { + "field": "date_created", + "type": "timestamp", + "meta": { + "special": [ + "date-created" + ], + "interface": "datetime", + "readonly": true + } + } + ], + "content_fragments": [ + { + "field": "id", + "type": "uuid", + "meta": { + "special": [ + "uuid" + ], + "interface": "input", + "readonly": true, + "hidden": true + } + }, + { + "field": "campaign", + "type": "uuid", + "meta": { + "interface": "select-dropdown-m2o", + "special": [ + "m2o" + ], + "required": true + } + }, + { + "field": "fragment_type", + "type": "string", + "meta": { + "interface": "select-dropdown", + "required": true, + "options": { + "choices": [ + { + "text": "Intro Hook", + "value": "intro_hook" + }, + { + "text": "Pillar 1: Keywords", + "value": "pillar_1_keyword" + }, + { + "text": "Pillar 2: Uniqueness", + "value": "pillar_2_uniqueness" + }, + { + "text": "Pillar 3: Relevance", + "value": "pillar_3_relevance" + }, + { + "text": "Pillar 4: Quality", + "value": "pillar_4_quality" + }, + { + "text": "Pillar 5: Authority", + "value": "pillar_5_authority" + }, + { + "text": "Pillar 6: Backlinks", + "value": "pillar_6_backlinks" + }, + { + "text": "FAQ Section", + "value": "faq_section" + } + ] + } + } + }, + { + "field": "content_body", + "type": "text", + "meta": { + "interface": "input-rich-text-html", + "required": true, + "note": "Use {variables} and {spintax|options}" + } + }, + { + "field": "word_count", + "type": "integer", + "meta": { + "interface": "input", + "readonly": true + } + }, + { + "field": "date_created", + "type": "timestamp", + "meta": { + "special": [ + "date-created" + ], + "interface": "datetime", + "readonly": true + } + } + ], + "generated_articles": [ + { + "field": "id", + "type": "uuid", + "meta": { + "special": [ + "uuid" + ], + "interface": "input", + "readonly": true, + "hidden": true + } + }, + { + "field": "site", + "type": "uuid", + "meta": { + "interface": "select-dropdown-m2o", + "special": [ + "m2o" + ], + "required": true + } + }, + { + "field": "campaign", + "type": "uuid", + "meta": { + "interface": "select-dropdown-m2o", + "special": [ + "m2o" + ] + } + }, + { + "field": "headline", + "type": "string", + "meta": { + "interface": "input", + "required": true + } + }, + { + "field": "meta_title", + "type": "string", + "meta": { + "interface": "input", + "note": "Max 70 characters" + } + }, + { + "field": "meta_description", + "type": "text", + "meta": { + "interface": "input-multiline", + "note": "Max 160 characters" + } + }, + { + "field": "full_html_body", + "type": "text", + "meta": { + "interface": "input-rich-text-html" + } + }, + { + "field": "word_count", + "type": "integer", + "meta": { + "interface": "input", + "readonly": true + } + }, + { + "field": "is_published", + "type": "boolean", + "meta": { + "interface": "boolean" + }, + "schema": { + "default_value": false + } + }, + { + "field": "featured_image", + "type": "uuid", + "meta": { + "interface": "file-image", + "special": [ + "file" + ] + } + }, + { + "field": "location_state", + "type": "string", + "meta": { + "interface": "input" + } + }, + { + "field": "location_county", + "type": "string", + "meta": { + "interface": "input" + } + }, + { + "field": "location_city", + "type": "string", + "meta": { + "interface": "input" + } + }, + { + "field": "date_created", + "type": "timestamp", + "meta": { + "special": [ + "date-created" + ], + "interface": "datetime", + "readonly": true + } + }, + { + "field": "date_updated", + "type": "timestamp", + "meta": { + "special": [ + "date-updated" + ], + "interface": "datetime", + "readonly": true + } + } + ], + "locations_states": [ + { + "field": "id", + "type": "uuid", + "meta": { + "special": [ + "uuid" + ], + "interface": "input", + "readonly": true, + "hidden": true + } + }, + { + "field": "name", + "type": "string", + "meta": { + "interface": "input", + "required": true + } + }, + { + "field": "code", + "type": "string", + "meta": { + "interface": "input", + "required": true, + "note": "Two-letter state code" + } + }, + { + "field": "country_code", + "type": "string", + "meta": { + "interface": "input" + }, + "schema": { + "default_value": "US" + } + } + ], + "locations_counties": [ + { + "field": "id", + "type": "uuid", + "meta": { + "special": [ + "uuid" + ], + "interface": "input", + "readonly": true, + "hidden": true + } + }, + { + "field": "name", + "type": "string", + "meta": { + "interface": "input", + "required": true + } + }, + { + "field": "state", + "type": "uuid", + "meta": { + "interface": "select-dropdown-m2o", + "special": [ + "m2o" + ], + "required": true + } + }, + { + "field": "fips_code", + "type": "string", + "meta": { + "interface": "input" + } + }, + { + "field": "population", + "type": "integer", + "meta": { + "interface": "input" + } + } + ], + "locations_cities": [ + { + "field": "id", + "type": "uuid", + "meta": { + "special": [ + "uuid" + ], + "interface": "input", + "readonly": true, + "hidden": true + } + }, + { + "field": "name", + "type": "string", + "meta": { + "interface": "input", + "required": true + } + }, + { + "field": "county", + "type": "uuid", + "meta": { + "interface": "select-dropdown-m2o", + "special": [ + "m2o" + ], + "required": true + } + }, + { + "field": "state", + "type": "uuid", + "meta": { + "interface": "select-dropdown-m2o", + "special": [ + "m2o" + ], + "required": true + } + }, + { + "field": "lat", + "type": "float", + "meta": { + "interface": "input" + } + }, + { + "field": "lng", + "type": "float", + "meta": { + "interface": "input" + } + }, + { + "field": "population", + "type": "integer", + "meta": { + "interface": "input" + } + }, + { + "field": "postal_code", + "type": "string", + "meta": { + "interface": "input" + } + }, + { + "field": "ranking", + "type": "integer", + "meta": { + "interface": "input", + "note": "Rank within county by population" + } + } + ], + "leads": [ + { + "field": "id", + "type": "uuid", + "meta": { + "special": [ + "uuid" + ], + "interface": "input", + "readonly": true, + "hidden": true + } + }, + { + "field": "site", + "type": "uuid", + "meta": { + "interface": "select-dropdown-m2o", + "special": [ + "m2o" + ], + "required": true + } + }, + { + "field": "name", + "type": "string", + "meta": { + "interface": "input" + } + }, + { + "field": "email", + "type": "string", + "meta": { + "interface": "input", + "required": true + } + }, + { + "field": "phone", + "type": "string", + "meta": { + "interface": "input" + } + }, + { + "field": "message", + "type": "text", + "meta": { + "interface": "input-multiline" + } + }, + { + "field": "source", + "type": "string", + "meta": { + "interface": "input" + } + }, + { + "field": "date_created", + "type": "timestamp", + "meta": { + "special": [ + "date-created" + ], + "interface": "datetime", + "readonly": true + } + } + ], + "image_templates": [ + { + "field": "id", + "type": "uuid", + "meta": { + "special": [ + "uuid" + ], + "interface": "input", + "readonly": true, + "hidden": true + } + }, + { + "field": "site", + "type": "uuid", + "meta": { + "interface": "select-dropdown-m2o", + "special": [ + "m2o" + ], + "note": "Leave empty for global template" + } + }, + { + "field": "name", + "type": "string", + "meta": { + "interface": "input", + "required": true + } + }, + { + "field": "svg_source", + "type": "text", + "meta": { + "interface": "input-code", + "options": { + "language": "xml" + }, + "required": true + } + }, + { + "field": "preview", + "type": "uuid", + "meta": { + "interface": "file-image", + "special": [ + "file" + ] + } + }, + { + "field": "is_default", + "type": "boolean", + "meta": { + "interface": "boolean" + }, + "schema": { + "default_value": false + } + }, + { + "field": "date_created", + "type": "timestamp", + "meta": { + "special": [ + "date-created" + ], + "interface": "datetime", + "readonly": true + } + } + ] +} \ No newline at end of file diff --git a/directus/template/src/relations.json b/directus/template/src/relations.json new file mode 100644 index 0000000..df64cdc --- /dev/null +++ b/directus/template/src/relations.json @@ -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 + } + } +] \ No newline at end of file diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..32831cd --- /dev/null +++ b/docker-compose.yaml @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..e5fd137 --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/astro.config.ts b/frontend/astro.config.ts new file mode 100644 index 0000000..db1e229 --- /dev/null +++ b/frontend/astro.config.ts @@ -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'] + } + } +}); diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..26659e7 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.mjs b/frontend/postcss.config.mjs new file mode 100644 index 0000000..d41ad63 --- /dev/null +++ b/frontend/postcss.config.mjs @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..aaa3bf8 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/frontend/src/components/admin/ArticleGenerator.tsx b/frontend/src/components/admin/ArticleGenerator.tsx new file mode 100644 index 0000000..ddd159a --- /dev/null +++ b/frontend/src/components/admin/ArticleGenerator.tsx @@ -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([]); + const [campaigns, setCampaigns] = useState([]); + 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 ; + } + + return ( +
+ {/* Generator Controls */} + + + Generate New Articles + + +
+
+ + +
+ +
+ + +
+ + +
+
+
+ + {/* Articles List */} +
+

Generated Articles ({articles.length})

+ +
+ +
+ {articles.length === 0 ? ( + + + No articles generated yet. Select a campaign and click Generate. + + + ) : ( + articles.map((article) => ( + + +
+
+
+

+ {article.headline} +

+ + {article.is_published ? 'Published' : 'Draft'} + +
+ +

{article.meta_title}

+ +
+ {article.word_count} words + {article.location_city && ( + {article.location_city}, {article.location_state} + )} + {new Date(article.date_created).toLocaleDateString()} +
+
+ +
+ + {!article.is_published && ( + + )} +
+
+
+
+ )) + )} +
+
+ ); +} diff --git a/frontend/src/components/admin/CampaignManager.tsx b/frontend/src/components/admin/CampaignManager.tsx new file mode 100644 index 0000000..ba25663 --- /dev/null +++ b/frontend/src/components/admin/CampaignManager.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [generating, setGenerating] = useState(null); + const [lastResult, setLastResult] = useState(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 ; + } + + return ( +
+
+

+ Manage your SEO campaigns with Cartesian Permutation headline generation. +

+ +
+ + {/* Generation Result Modal */} + {lastResult && ( + + +
+
+

+ βœ“ Headlines Generated Successfully +

+ +
+
+ Spintax Slots: +

{lastResult.metadata.slotCount}

+
+
+ Spintax Combinations: +

{lastResult.metadata.spintaxCombinations.toLocaleString()}

+
+
+ Locations: +

{lastResult.metadata.locationCount.toLocaleString()}

+
+
+ Total Possible (nΓ—k): +

{lastResult.metadata.totalPossible.toLocaleString()}

+
+
+ +
+ + Inserted: {lastResult.results.inserted.toLocaleString()} + + + Skipped (duplicates): {lastResult.results.skipped.toLocaleString()} + + + Already existed: {lastResult.results.alreadyExisted.toLocaleString()} + +
+ + {lastResult.metadata.wasTruncated && ( +

+ ⚠ Results truncated to 10,000 headlines (safety limit) +

+ )} +
+ + +
+
+
+ )} + + {showForm && ( + + + Create New Campaign + + +
+ setFormData({ ...formData, name: e.target.value })} + placeholder="e.g., Local Dental SEO" + required + /> + +
+