feat(content): implement Content Engine MVP (v1.0) with GTM integration

This commit is contained in:
2026-01-20 12:45:59 +00:00
parent 401ad7e6e8
commit 41e60c72bc
17 changed files with 1169 additions and 30 deletions

42
content-engine/Dockerfile Normal file
View File

@@ -0,0 +1,42 @@
FROM node:20-slim AS frontend-build
WORKDIR /app/frontend
# Correct path relative to build context (root)
COPY content-engine/frontend/package*.json ./
RUN npm install
COPY content-engine/frontend/ ./
RUN npm run build
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
curl \
gnupg \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY content-engine/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Install Backend Node dependencies
COPY content-engine/package.json ./
RUN npm install
# Copy backend files
COPY content-engine/*.py ./
COPY content-engine/server.cjs ./
# Helpers and Config from root
COPY helpers.py ./
COPY config.py ./
# Copy built frontend
COPY --from=frontend-build /app/frontend/dist ./dist
# Keys and persistence placeholders
RUN touch gemini_api_key.txt serpapikey.txt
EXPOSE 3006
CMD ["node", "server.cjs"]

View File

@@ -1,11 +1,12 @@
# Content Engine (v1.0 - MVP)
**Status:** Planning / Initial Setup
**Status:** Live / MVP Implemented
**Date:** Jan 20, 2026
**URL:** `/content/`
## 1. Vision & Purpose
The **Content Engine** acts as the execution arm ("The Mouth") for the strategies developed in the GTM Architect ("The Brain").
It is a **Content Generation Dashboard** designed to produce high-quality, SEO-optimized, and sales-ready marketing assets.
It is a **Content Generation Dashboard** designed to produce high-quality, SEO-optimized, and sales-ready marketing assets based on the strategic foundation of the GTM Architect.
**Core Philosophy:**
* **SEO First:** Keywords guide the structure, not just metadata.
@@ -17,43 +18,49 @@ It is a **Content Generation Dashboard** designed to produce high-quality, SEO-o
### Data Layer
* **Persistence:** A dedicated SQLite database (`content_engine.db`) stores all content projects, SEO strategies, and drafts.
* **Integration:** Read-only access to `gtm_projects.db` to import strategy baselines.
* **Integration:** Read-only access to `gtm_projects.db` via Docker volume mounts to import strategy baselines.
### The Stack
* **Frontend:** React (Vite + TypeScript) - Focus on "Writer UI" (Split Screen: Config vs. Editor).
* **Backend:** Python (Flask/Process-based Orchestrator) - Utilizing `helpers.py` for AI interaction.
* **Container:** Dockerized service, integrated into the existing Marketing Hub network.
* **Frontend:** React (Vite + TypeScript + Tailwind CSS).
* **Backend:** Node.js Bridge (`server.cjs`, Express) communicating with a Python Orchestrator (`content_orchestrator.py`).
* **Container:** Dockerized service (`content-app`), integrated into the central Nginx Gateway.
## 3. Workflow (MVP Scope: Website & SEO)
## 3. Implemented Features (MVP)
### Phase 1: Project Setup & Import
1. Select a source project from GTM Architect (e.g., "PUMA M20").
2. Import core data: Product Category, Hybrid Logic, Pain Points per ICP.
3. **Competitor Scan:** Optional input of competitor URLs to analyze their tone and position against it.
* [x] **GTM Bridge:** Lists and imports strategies directly from GTM Architect.
* [x] **Context Loading:** Automatically extracts Product Category, ICPs, and Core Value Propositions.
### Phase 2: SEO Strategy
1. **Seed Generation:** AI suggests seed keywords based on GTM data.
2. **Expansion & Validation:** Use Google Search/Suggest (via Gemini Tools) to find real-world query patterns.
3. **Selection:** User selects Primary and Secondary Keywords.
* [x] **AI Brainstorming:** Generates 15 strategic Seed Keywords (Short & Long Tail) based on the imported strategy.
* [x] **Persistence:** Saves the chosen SEO strategy to the database.
### Phase 3: Structure & Copy Generation
1. **Sitemap:** AI proposes a site structure (Home, Use Case Pages, Tech Specs).
2. **Section Generation:**
* **Hero:** Headline (Keyword-focused) + Subline.
* **Value Prop:** "Pain vs. Solution" (from GTM Phase 4).
* **Features:** "Feature-to-Value" (from GTM Phase 9).
* **Proof:** FAQ & Objections (from GTM Phase 6 - Battlecards).
3. **Refinement:** "Re-Roll" buttons for specific sections (e.g., "Make it punchier", "More focus on Compliance").
### Phase 3: Website Copy Generator
* [x] **Section Generator:** Generates structured copy for:
* **Hero Section** (Headline, Subline, CTA)
* **Challenger Story** (Problem/Agitation)
* **Value Proposition** (Hybrid Solution Logic)
* **Feature-to-Value** (Tech Deep Dive)
* [x] **Editor UI:** Integrated Markdown editor for manual refinement.
* [x] **Copy-to-Clipboard:** Quick export for deployment.
### Phase 4: Export
* Copy to Clipboard (Markdown/HTML).
* PDF Export.
## 4. Lessons Learned (Development Log)
## 4. Future Modules (Post-MVP)
### Docker & Networking
* **Volume Mounts:** Never mount a local folder over a container folder that contains build artifacts (like `node_modules` or `dist`). *Solution:* Build frontend inside Docker and serve via Node/Express static files, or be extremely precise with volume mounts.
* **Nginx Routing:** Frontend fetch calls must use **relative paths** (e.g., `api/import` instead of `/api/import`) to respect the reverse proxy path (`/content/`). Absolute paths lead to 404/502 errors because Nginx tries to route them to the root.
* **502 Bad Gateway:** Often caused by the Node server crashing immediately on startup. *Common cause:* Missing backend dependencies (like `express`) in the Docker image because `package.json` wasn't copied/installed for the backend context.
### Frontend (Vite/React)
* **TypeScript Configuration:** `tsc` requires a valid `tsconfig.json`. Without it, `npm run build` fails silently or with obscure errors.
* **Linting vs. Prototyping:** Strict linting (`noUnusedLocals: true`) is good for production but blocks rapid prototyping. *Solution:* Relax rules in `tsconfig.json` during MVP phase.
* **ES Modules vs. CommonJS:** When `package.json` has `"type": "module"`, configuration files like `postcss.config.js` MUST be renamed to `.cjs` if they use `module.exports`.
### Python & Backend
* **Standard Libs:** Do NOT include standard libraries like `sqlite3` in `requirements.txt`. Pip will fail.
* **Strings in Prompts:** ALWAYS use `r"""..."""` (Raw Strings) for prompts to avoid syntax errors with curly braces in JSON templates.
## 5. Roadmap
* **LinkedIn Matrix:** Generate posts for (Persona x Content Type).
* **Outbound Email:** Cold outreach sequences.
* **Press Kit:** Automated PR generation.
## 5. Quick Actions ("Türen öffnen")
A dashboard feature to bypass the full project flow:
* *"Write a LinkedIn post about [Feature] for [Role]."*
* **Press Kit:** Automated PR generation.

View File

@@ -0,0 +1,179 @@
import sqlite3
import json
import os
import logging
from datetime import datetime
DB_PATH = os.environ.get('DB_PATH', 'content_engine.db')
GTM_DB_PATH = os.environ.get('GTM_DB_PATH', 'gtm_projects.db')
def get_db_connection(path=DB_PATH):
conn = sqlite3.connect(path)
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = get_db_connection()
cursor = conn.cursor()
# Projects table
cursor.execute('''
CREATE TABLE IF NOT EXISTS content_projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
gtm_project_id TEXT,
category TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
gtm_data_snapshot TEXT, -- Full JSON snapshot of GTM data at import
seo_strategy TEXT, -- JSON blob
site_structure TEXT, -- JSON blob
metadata TEXT -- JSON blob
)
''')
# Content Assets table
cursor.execute('''
CREATE TABLE IF NOT EXISTS content_assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER,
asset_type TEXT NOT NULL, -- 'website_section', 'linkedin', 'email', 'pr'
section_key TEXT, -- e.g., 'hero', 'features', 'faq'
title TEXT,
content TEXT, -- Markdown content
status TEXT DEFAULT 'draft',
keywords TEXT, -- JSON list of used keywords
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES content_projects (id) ON DELETE CASCADE
)
''')
conn.commit()
conn.close()
logging.info(f"Database initialized at {DB_PATH}")
# --- GTM READ ACCESS ---
def get_all_gtm_projects():
"""Lists all available GTM projects."""
if not os.path.exists(GTM_DB_PATH):
logging.warning(f"GTM DB not found at {GTM_DB_PATH}")
return []
conn = get_db_connection(GTM_DB_PATH)
try:
query = """
SELECT
id,
name,
updated_at,
json_extract(data, '$.phases.phase1_result.category') AS productCategory
FROM gtm_projects
ORDER BY updated_at DESC
"""
projects = [dict(row) for row in conn.execute(query).fetchall()]
return projects
finally:
conn.close()
def get_gtm_project_data(gtm_id):
"""Retrieves full data for a GTM project."""
conn = get_db_connection(GTM_DB_PATH)
try:
row = conn.execute("SELECT data FROM gtm_projects WHERE id = ?", (gtm_id,)).fetchone()
return json.loads(row['data']) if row else None
finally:
conn.close()
# --- CONTENT ENGINE OPERATIONS ---
def import_gtm_project(gtm_id):
"""Imports a GTM project as a new Content Engine project."""
gtm_data = get_gtm_project_data(gtm_id)
if not gtm_data:
return None
name = gtm_data.get('name', 'Imported Project')
# Phase 1 has the category
phase1 = gtm_data.get('phases', {}).get('phase1_result', {})
if isinstance(phase1, str): phase1 = json.loads(phase1)
category = phase1.get('category', 'Unknown')
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute(
"INSERT INTO content_projects (name, gtm_project_id, category, gtm_data_snapshot) VALUES (?, ?, ?, ?)",
(name, gtm_id, category, json.dumps(gtm_data))
)
project_id = cursor.lastrowid
conn.commit()
conn.close()
return {"id": project_id, "name": name, "category": category}
def get_all_content_projects():
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT id, name, category, created_at, gtm_project_id FROM content_projects ORDER BY updated_at DESC")
projects = [dict(row) for row in cursor.fetchall()]
conn.close()
return projects
def get_content_project(project_id):
conn = get_db_connection()
row = conn.execute("SELECT * FROM content_projects WHERE id = ?", (project_id,)).fetchone()
conn.close()
if row:
d = dict(row)
if d['gtm_data_snapshot']: d['gtm_data_snapshot'] = json.loads(d['gtm_data_snapshot'])
if d['seo_strategy']: d['seo_strategy'] = json.loads(d['seo_strategy'])
return d
return None
def save_seo_strategy(project_id, strategy_dict):
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute(
"UPDATE content_projects SET seo_strategy = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(json.dumps(strategy_dict), project_id)
)
conn.commit()
conn.close()
def save_content_asset(project_id, asset_type, section_key, title, content, keywords=None):
conn = get_db_connection()
cursor = conn.cursor()
# Check if exists (upsert logic for sections)
cursor.execute(
"SELECT id FROM content_assets WHERE project_id = ? AND asset_type = ? AND section_key = ?",
(project_id, asset_type, section_key)
)
existing = cursor.fetchone()
if existing:
cursor.execute(
"UPDATE content_assets SET title = ?, content = ?, keywords = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(title, content, json.dumps(keywords) if keywords else None, existing['id'])
)
asset_id = existing['id']
else:
cursor.execute(
"INSERT INTO content_assets (project_id, asset_type, section_key, title, content, keywords) VALUES (?, ?, ?, ?, ?, ?)",
(project_id, asset_type, section_key, title, content, json.dumps(keywords) if keywords else None)
)
asset_id = cursor.lastrowid
conn.commit()
conn.close()
return asset_id
def get_project_assets(project_id):
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT * FROM content_assets WHERE project_id = ?", (project_id,))
assets = [dict(row) for row in cursor.fetchall()]
conn.close()
return assets
if __name__ == "__main__":
init_db()

View File

@@ -0,0 +1,181 @@
import argparse
import base64
import json
import logging
import sys
import os
from datetime import datetime
import content_db_manager as db_manager
# Ensure helper path is correct
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from helpers import call_gemini_flash, scrape_website_details
from config import Config
LOG_DIR = "Log_from_docker"
if not os.path.exists(LOG_DIR):
os.makedirs(LOG_DIR)
run_timestamp = datetime.now().strftime("%y-%m-%d_%H-%M-%S")
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
Config.load_api_keys()
def get_copywriter_instruction(lang='de'):
return r"""
Du bist ein Senior Copywriter und SEO-Experte. Deine Spezialität ist der 'Challenger Sale'.
Du schreibst Texte, die fachlich tief fundiert, professionell und leicht aggressiv/fordernd sind.
DEIN STIL:
- Keine Buzzwords ohne Substanz.
- Fokus auf den 'Cost of Inaction' (Was kostet es den Kunden, wenn er NICHT handelt?).
- Übersetzung von Technik in geschäftlichen Nutzen.
- SEO-Integration: Baue Keywords natürlich aber präsent ein.
"""
# --- MODES ---
def list_gtm_projects(payload):
projects = db_manager.get_all_gtm_projects()
return {"projects": projects}
def import_project(payload):
gtm_id = payload.get('gtmProjectId')
if not gtm_id:
return {"error": "Missing gtmProjectId"}
result = db_manager.import_gtm_project(gtm_id)
if not result:
return {"error": "GTM Project not found or import failed"}
return result
def list_content_projects(payload):
projects = db_manager.get_all_content_projects()
return {"projects": projects}
def load_project_details(payload):
project_id = payload.get('projectId')
project = db_manager.get_content_project(project_id)
if not project:
return {"error": "Project not found"}
assets = db_manager.get_project_assets(project_id)
project['assets'] = assets
return project
def seo_brainstorming(payload):
project_id = payload.get('projectId')
lang = payload.get('lang', 'de')
project = db_manager.get_content_project(project_id)
if not project:
return {"error": "Project context not found"}
gtm_data = project.get('gtm_data_snapshot', {})
# GOLDEN RULE: Use Raw Quotes and .format()
prompt = r"""
Basierend auf folgendem GTM-Kontext (Strategie für ein technisches Produkt):
{gtm_context}
AUFGABE:
Generiere eine strategische Liste von 15 SEO-Keywords.
1. 5 Short-Tail Fokus-Keywords (z.B. Produktkategorie + 'kaufen/mieten').
2. 10 Long-Tail Keywords, die spezifische Pain Points oder Usecases adressieren (z.B. 'Kostenreduktion bei Sicherheitsrundgängen').
Die Keywords müssen für Entscheider relevant sein (CFO, Head of Security, Operations Manager).
Output NUR als JSON Liste von Strings.
""".format(gtm_context=json.dumps(gtm_data))
response = call_gemini_flash(prompt, system_instruction=get_copywriter_instruction(lang), json_mode=True)
keywords = json.loads(response)
db_manager.save_seo_strategy(project_id, {"seed_keywords": keywords})
return {"keywords": keywords}
def generate_section(payload):
project_id = payload.get('projectId')
section_key = payload.get('sectionKey') # e.g., 'hero', 'problem', 'features'
manual_content = payload.get('manualContent')
lang = payload.get('lang', 'de')
keywords = payload.get('keywords', [])
if manual_content:
# User is saving their manual edits
db_manager.save_content_asset(project_id, 'website_section', section_key, f"Section: {section_key}", manual_content, keywords)
return {"status": "saved", "sectionKey": section_key}
project = db_manager.get_content_project(project_id)
if not project:
return {"error": "Project context not found"}
gtm_data = project.get('gtm_data_snapshot', {})
# Context extraction
category = project.get('category')
prompt = r"""
Erstelle den Website-Inhalt für die Sektion '{section}' eines Produkts in der Kategorie '{cat}'.
STRATEGIE-KONTEXT:
{gtm_context}
SEO-KEYWORDS ZU NUTZEN:
{kws}
ANFORDERUNG:
- Schreibe im Stil eines Senior Copywriters (fachlich fundiert, Challenger Sale).
- Format: Markdown.
- Die Sektion muss den Nutzer zur nächsten Aktion (CTA) führen.
""".format(
section=section_key,
cat=category,
gtm_context=json.dumps(gtm_data),
kws=json.dumps(keywords)
)
content = call_gemini_flash(prompt, system_instruction=get_copywriter_instruction(lang), json_mode=False)
# Save as asset
db_manager.save_content_asset(project_id, 'website_section', section_key, f"Section: {section_key}", content, keywords)
return {"content": content, "sectionKey": section_key}
def main():
parser = argparse.ArgumentParser(description="Content Engine Orchestrator")
parser.add_argument("--mode", required=True)
parser.add_argument("--payload_file", help="Path to JSON payload")
args = parser.parse_args()
payload = {}
if args.payload_file:
with open(args.payload_file, 'r') as f:
payload = json.load(f)
modes = {
"list_gtm_projects": list_gtm_projects,
"import_project": import_project,
"list_content_projects": list_content_projects,
"load_project": load_project_details,
"seo_brainstorming": seo_brainstorming,
"generate_section": generate_section,
}
if args.mode in modes:
try:
result = modes[args.mode](payload)
print(json.dumps(result, ensure_ascii=False))
except Exception as e:
logging.error(f"Error in mode {args.mode}: {str(e)}")
print(json.dumps({"error": str(e)}))
else:
print(json.dumps({"error": f"Unknown mode: {args.mode}"}))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Content Engine</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,26 @@
{
"name": "content-engine-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"lucide-react": "^0.263.1",
"clsx": "^2.0.0",
"tailwind-merge": "^1.14.0",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@vitejs/plugin-react": "^4.0.3",
"typescript": "^5.0.2",
"vite": "^4.4.5",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.27",
"tailwindcss": "^3.3.3"
}
}

View File

@@ -0,0 +1,7 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,509 @@
import { useState, useEffect } from 'react';
import {
Rocket,
Search,
FileText,
ArrowRight,
ChevronLeft,
Database,
Plus,
RefreshCw,
Edit3
} from 'lucide-react';
// --- TYPES ---
interface GTMProject {
id: string;
name: string;
productCategory: string;
}
interface ContentProject {
id: number;
name: string;
category: string;
gtm_project_id: string;
created_at: string;
seo_strategy?: { seed_keywords?: string[] };
assets?: ContentAsset[];
}
interface ContentAsset {
id: number;
section_key: string;
content: string;
status: string;
}
// --- SUB-COMPONENTS ---
function SEOPlanner({ project, setLoading }: { project: ContentProject, setLoading: (b: boolean) => void }) {
const [keywords, setKeywords] = useState<string[]>(project.seo_strategy?.seed_keywords || []);
const generateKeywords = async () => {
setLoading(true);
try {
// FIX: Relative path
const res = await fetch('api/seo_brainstorming', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId: project.id })
});
const data = await res.json();
setKeywords(data.keywords || []);
} catch (err) { console.error(err); }
setLoading(false);
};
return (
<div className="space-y-6 animate-in fade-in">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold mb-1">SEO Strategy</h2>
<p className="text-slate-400 text-sm">Define the keywords that drive your content structure.</p>
</div>
<button
onClick={generateKeywords}
className="bg-slate-700 hover:bg-slate-600 text-white px-4 py-2 rounded-xl text-sm font-medium flex items-center gap-2 transition-colors"
>
<RefreshCw size={16} /> {keywords.length > 0 ? 'Refresh Keywords' : 'Generate Keywords'}
</button>
</div>
{keywords.length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{keywords.map((kw, i) => (
<div key={i} className="bg-slate-900 border border-slate-700 p-4 rounded-xl flex items-center gap-3 hover:border-slate-600 transition-colors">
<span className="text-slate-600 text-xs font-mono">{String(i+1).padStart(2, '0')}</span>
<span className="font-medium text-slate-200">{kw}</span>
</div>
))}
</div>
) : (
<div className="bg-slate-900/50 rounded-2xl p-12 text-center border border-slate-800 border-dashed">
<p className="text-slate-500 italic">No keywords generated yet. Start here!</p>
</div>
)}
</div>
);
}
function WebsiteBuilder({ project, setLoading }: { project: ContentProject, setLoading: (b: boolean) => void }) {
const [sections, setSections] = useState<ContentAsset[]>(project.assets || []);
const [editingContent, setEditingContent] = useState<{ [key: string]: string }>({});
useEffect(() => {
const newEditing: { [key: string]: string } = {};
if (sections) {
sections.forEach(s => {
newEditing[s.section_key] = s.content;
});
}
setEditingContent(newEditing);
}, [sections]);
const generateSection = async (key: string) => {
setLoading(true);
try {
// FIX: Relative path
const res = await fetch('api/generate_section', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectId: project.id,
sectionKey: key,
keywords: project.seo_strategy?.seed_keywords || []
})
});
const data = await res.json();
setSections(prev => {
const other = prev.filter(s => s.section_key !== key);
return [...other, { id: Date.now(), section_key: key, content: data.content, status: 'draft' }];
});
} catch (err) { console.error(err); }
setLoading(false);
};
const handleEditChange = (key: string, val: string) => {
setEditingContent(prev => ({ ...prev, [key]: val }));
};
const saveEdit = async (key: string) => {
const content = editingContent[key];
if (!content) return;
setLoading(true);
try {
// FIX: Relative path
await fetch('api/generate_section', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectId: project.id,
sectionKey: key,
manualContent: content
})
});
alert("Saved successfully!");
} catch (err) { console.error(err); }
setLoading(false);
};
const copyToClipboard = (val: string) => {
navigator.clipboard.writeText(val);
alert('Copied to clipboard!');
};
return (
<div className="space-y-8 animate-in fade-in">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold mb-1">Website Copy Sections</h2>
<p className="text-slate-400 text-sm">Generate and refine high-converting blocks based on your strategy.</p>
</div>
</div>
<div className="space-y-8">
{[
{ key: 'hero', label: 'Hero Section', desc: 'Headline, Subline & CTA' },
{ key: 'problem', label: 'The Challenger Story', desc: 'Pain Points & Consequences' },
{ key: 'value_prop', label: 'Hybrid Solution', desc: 'Symbiosis of Machine & Human' },
{ key: 'features', label: 'Feature-to-Value', desc: 'Benefit-driven Tech Deep Dive' }
].map(s => {
const hasContent = editingContent[s.key] !== undefined;
return (
<div key={s.key} className="bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden shadow-lg shadow-black/20 transition-all hover:border-slate-700">
<div className="p-6 border-b border-slate-800 flex items-center justify-between bg-slate-900/50">
<div className="flex items-center gap-3">
<div className="bg-slate-800 p-2 rounded-lg text-blue-500">
<FileText size={18} />
</div>
<div>
<h3 className="font-bold text-lg text-slate-200">{s.label}</h3>
<p className="text-slate-500 text-xs">{s.desc}</p>
</div>
</div>
<div className="flex items-center gap-2">
{hasContent && (
<button
onClick={() => copyToClipboard(editingContent[s.key])}
className="text-slate-400 hover:text-white p-2 transition-colors rounded-lg hover:bg-slate-800"
title="Copy Markdown"
>
<FileText size={16} />
</button>
)}
<button
onClick={() => generateSection(s.key)}
className="bg-blue-600/10 hover:bg-blue-600/20 text-blue-400 px-4 py-2 rounded-xl text-xs font-bold transition-all flex items-center gap-2 border border-transparent hover:border-blue-500/30"
>
<RefreshCw size={14} /> {hasContent ? 'Re-Generate' : 'Generate Draft'}
</button>
</div>
</div>
<div className="p-6 bg-slate-950/30">
{hasContent ? (
<div className="space-y-4">
<textarea
value={editingContent[s.key]}
onChange={(e) => handleEditChange(s.key, e.target.value)}
className="w-full min-h-[300px] bg-slate-900/80 border border-slate-800 rounded-xl p-4 text-sm font-mono text-slate-300 focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition-all outline-none leading-relaxed resize-y"
/>
<div className="flex justify-end">
<button
onClick={() => saveEdit(s.key)}
className="bg-slate-800 hover:bg-slate-700 text-slate-200 px-4 py-2 rounded-lg text-xs font-bold transition-colors"
>
Save Changes
</button>
</div>
</div>
) : (
<div className="text-center py-12">
<p className="text-slate-600 italic text-sm mb-4">No content yet...</p>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
}
function ProjectDashboard({ project, onBack, setLoading }: { project: ContentProject, onBack: () => void, setLoading: (b: boolean) => void }) {
const [activeTab, setActiveTab] = useState<'SEO' | 'WEBSITE' | 'SOCIAL'>('SEO');
return (
<div className="space-y-8 animate-in fade-in slide-in-from-bottom-2">
<div className="flex items-center justify-between">
<button onClick={onBack} className="flex items-center gap-2 text-slate-400 hover:text-white transition-colors group">
<ChevronLeft size={20} className="group-hover:-translate-x-1 transition-transform" /> Back to Campaigns
</button>
<span className="text-xs font-mono text-slate-600 bg-slate-900 px-2 py-1 rounded border border-slate-800">ID: {project.id}</span>
</div>
<div className="bg-slate-800 border border-slate-700 rounded-3xl p-8 shadow-2xl">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 mb-8">
<div>
<h1 className="text-3xl font-bold mb-2 text-white">{project.name}</h1>
<p className="text-slate-400 flex items-center gap-2 text-sm">
<Rocket size={16} className="text-blue-500" />
Category: <span className="text-slate-200 font-medium">{project.category}</span>
</p>
</div>
<div className="flex bg-slate-900/80 p-1.5 rounded-2xl border border-slate-700 backdrop-blur-sm">
{[
{ id: 'SEO', label: 'SEO Plan', icon: Search },
{ id: 'WEBSITE', label: 'Website Copy', icon: FileText },
{ id: 'SOCIAL', label: 'LinkedIn', icon: Edit3 },
].map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={`flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold transition-all ${
activeTab === tab.id
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/30'
: 'text-slate-400 hover:text-white hover:bg-slate-800'
}`}
>
<tab.icon size={16} />
{tab.label}
</button>
))}
</div>
</div>
{/* Tab Content */}
<div className="mt-8 pt-8 border-t border-slate-700">
{activeTab === 'SEO' && <SEOPlanner project={project} setLoading={setLoading} />}
{activeTab === 'WEBSITE' && <WebsiteBuilder project={project} setLoading={setLoading} />}
{activeTab === 'SOCIAL' && (
<div className="text-center py-20 bg-slate-900/30 rounded-2xl border border-slate-700 border-dashed">
<Edit3 size={48} className="mx-auto text-slate-700 mb-4" />
<p className="text-slate-500 italic">LinkedIn Content Matrix coming soon...</p>
</div>
)}
</div>
</div>
</div>
);
}
// --- MAIN APP ---
export default function App() {
const [view, setView] = useState<'LIST' | 'IMPORT' | 'DETAILS'>('LIST');
const [contentProjects, setContentProjects] = useState<ContentProject[]>([]);
const [gtmProjects, setGtmProjects] = useState<GTMProject[]>([]);
const [selectedProject, setSelectedProject] = useState<ContentProject | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
fetchContentProjects();
}, []);
const fetchContentProjects = async () => {
setLoading(true);
try {
// FIX: Relative path
const res = await fetch('api/list_content_projects', { method: 'POST', body: '{}', headers: {'Content-Type': 'application/json'} });
const data = await res.json();
setContentProjects(data.projects || []);
} catch (err) { console.error(err); }
setLoading(false);
};
const fetchGtmProjects = async () => {
setLoading(true);
try {
// FIX: Relative path
const res = await fetch('api/list_gtm_projects', { method: 'POST', body: '{}', headers: {'Content-Type': 'application/json'} });
const data = await res.json();
setGtmProjects(data.projects || []);
setView('IMPORT');
} catch (err) { console.error(err); }
setLoading(false);
};
const handleImport = async (gtmId: string) => {
setLoading(true);
try {
// FIX: Relative path
const res = await fetch('api/import_project', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ gtmProjectId: gtmId })
});
const data = await res.json();
if (data.id) {
await fetchContentProjects();
setView('LIST');
}
} catch (err) { console.error(err); }
setLoading(false);
};
const loadProject = async (id: number) => {
setLoading(true);
try {
// FIX: Relative path
const res = await fetch('api/load_project', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId: id })
});
const data = await res.json();
setSelectedProject(data);
setView('DETAILS');
} catch (err) { console.error(err); }
setLoading(false);
};
return (
<div className="min-h-screen bg-slate-900 text-slate-100 font-sans selection:bg-blue-500/30">
{/* Header */}
<header className="border-b border-slate-800 bg-slate-900/80 backdrop-blur-md sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
<div className="flex items-center gap-3 cursor-pointer group" onClick={() => setView('LIST')}>
<div className="bg-blue-600 p-2 rounded-lg group-hover:bg-blue-500 transition-colors">
<Edit3 size={20} className="text-white" />
</div>
<span className="text-xl font-bold tracking-tight">Content Engine <span className="text-blue-500 font-normal ml-1 opacity-50">v1.0</span></span>
</div>
<div className="flex items-center gap-4">
{loading && (
<div className="flex items-center gap-2 bg-slate-800 px-3 py-1.5 rounded-full border border-slate-700">
<RefreshCw className="animate-spin text-blue-500" size={14} />
<span className="text-xs text-slate-300 font-medium">Processing...</span>
</div>
)}
<div className="h-6 w-px bg-slate-800 mx-2" />
<button
onClick={fetchGtmProjects}
className="bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded-lg text-sm font-bold transition-all shadow-lg shadow-blue-900/20 active:scale-95 flex items-center gap-2"
>
<Plus size={18} /> New Campaign
</button>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-6 py-10">
{view === 'LIST' && (
<div className="space-y-8 animate-in fade-in duration-500">
<h2 className="text-2xl font-bold flex items-center gap-3">
<Database className="text-slate-500" size={24} /> Active Campaigns
</h2>
{contentProjects.length === 0 ? (
<div className="bg-slate-800/30 border border-dashed border-slate-700 rounded-3xl p-16 text-center">
<div className="inline-flex bg-slate-800 p-4 rounded-full mb-6 text-slate-600">
<Rocket size={32} />
</div>
<h3 className="text-xl font-bold text-white mb-2">No active campaigns yet</h3>
<p className="text-slate-400 mb-8 max-w-md mx-auto">Start by importing a strategy from the GTM Architect to turn your plan into actionable content.</p>
<button
onClick={fetchGtmProjects}
className="bg-slate-800 hover:bg-slate-700 text-white px-6 py-3 rounded-xl font-bold transition-colors inline-flex items-center gap-2"
>
Start First Campaign <ArrowRight size={18} />
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{contentProjects.map(p => (
<div
key={p.id}
onClick={() => loadProject(p.id)}
className="bg-slate-800 border border-slate-700 p-6 rounded-2xl hover:border-blue-500 hover:shadow-xl hover:shadow-blue-900/10 transition-all cursor-pointer group relative overflow-hidden"
>
<div className="absolute top-0 right-0 p-6 opacity-10 group-hover:opacity-20 transition-opacity">
<Rocket size={64} />
</div>
<div className="relative z-10">
<div className="flex justify-between items-start mb-4">
<span className="text-[10px] font-bold uppercase tracking-wider text-blue-300 px-2 py-1 bg-blue-500/20 rounded">
{p.category}
</span>
</div>
<h3 className="text-xl font-bold mb-2 group-hover:text-blue-400 transition-colors line-clamp-2">{p.name}</h3>
<p className="text-sm text-slate-500 mb-6">Started: {new Date(p.created_at).toLocaleDateString()}</p>
<div className="flex items-center text-blue-500 font-bold text-sm">
Open Dashboard <ArrowRight size={16} className="ml-2 group-hover:translate-x-1 transition-transform" />
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{view === 'IMPORT' && (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4">
<div className="flex items-center justify-between">
<button onClick={() => setView('LIST')} className="flex items-center gap-2 text-slate-400 hover:text-white transition-colors font-medium">
<ChevronLeft size={20} /> Back to Campaigns
</button>
</div>
<div className="bg-slate-800 border border-slate-700 rounded-3xl p-8 shadow-2xl">
<div className="mb-8">
<h2 className="text-2xl font-bold mb-2 text-white">Import GTM Strategy</h2>
<p className="text-slate-400">Select a validated strategy from the GTM Architect to build your content engine.</p>
</div>
{gtmProjects.length === 0 ? (
<div className="py-16 text-center text-slate-500 italic border border-dashed border-slate-700 rounded-2xl bg-slate-900/50">
<p className="mb-4">No strategies found in GTM Architect.</p>
<a href="/gtm/" className="text-blue-400 hover:text-blue-300 underline underline-offset-4">Go to GTM Architect &rarr;</a>
</div>
) : (
<div className="grid grid-cols-1 gap-4">
{gtmProjects.map(p => (
<div key={p.id} className="bg-slate-900/50 border border-slate-800 p-5 rounded-2xl flex items-center justify-between hover:border-slate-600 transition-all group">
<div className="flex items-center gap-5">
<div className="bg-slate-800 p-3 rounded-xl text-blue-400 group-hover:scale-110 transition-transform shadow-inner">
<Rocket size={24} />
</div>
<div>
<h3 className="font-bold text-lg text-slate-200">{p.name}</h3>
<div className="flex items-center gap-3 mt-1">
<span className="text-[10px] font-bold uppercase tracking-wider bg-slate-800 text-slate-400 px-2 py-0.5 rounded border border-slate-700">
{p.productCategory}
</span>
<span className="text-[10px] text-slate-600 font-mono">ID: {p.id.split('-')[0]}...</span>
</div>
</div>
</div>
<button
onClick={() => handleImport(p.id)}
className="bg-blue-600 hover:bg-blue-500 text-white px-6 py-2.5 rounded-xl font-bold text-sm transition-all shadow-lg shadow-blue-900/20 active:scale-95 flex items-center gap-2"
>
Start Campaign <ArrowRight size={16} />
</button>
</div>
))}
</div>
)}
</div>
</div>
)}
{view === 'DETAILS' && selectedProject && (
<ProjectDashboard
project={selectedProject}
onBack={() => setView('LIST')}
setLoading={setLoading}
/>
)}
</main>
</div>
);
}

View File

@@ -0,0 +1,8 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-slate-900 text-slate-100;
}

View File

@@ -0,0 +1,11 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
base: './', // CRITICAL for sub-path deployment
server: {
proxy: {
'/api': {
target: 'http://localhost:3006',
changeOrigin: true,
}
}
}
})

View File

@@ -0,0 +1,14 @@
{
"name": "content-engine-backend",
"version": "1.0.0",
"description": "Backend bridge for Content Engine",
"main": "server.cjs",
"type": "commonjs",
"dependencies": {
"express": "^4.18.2"
},
"scripts": {
"start": "node server.cjs"
}
}

View File

@@ -0,0 +1,5 @@
requests
beautifulsoup4
google-generativeai
google-genai

68
content-engine/server.cjs Normal file
View File

@@ -0,0 +1,68 @@
const express = require('express');
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const app = express();
const port = process.env.PORT || 3006;
app.use(express.json({ limit: '50mb' }));
// INITIALIZE DATABASE ON START
const dbScript = path.join(__dirname, 'content_db_manager.py');
console.log("Initializing database...");
spawn('python3', [dbScript]);
// Helper to run python commands
function runPython(mode, payload) {
return new Promise((resolve, reject) => {
const payloadFile = path.join(__dirname, `payload_${Date.now()}.json`);
fs.writeFileSync(payloadFile, JSON.stringify(payload));
const pythonProcess = spawn('python3', [
path.join(__dirname, 'content_orchestrator.py'),
'--mode', mode,
'--payload_file', payloadFile
]);
let stdout = '';
let stderr = '';
pythonProcess.stdout.on('data', (data) => stdout += data.toString());
pythonProcess.stderr.on('data', (data) => stderr += data.toString());
pythonProcess.on('close', (code) => {
if (fs.existsSync(payloadFile)) fs.unlinkSync(payloadFile);
if (code !== 0) {
console.error(`Python error (code ${code}):`, stderr);
return reject(stderr);
}
try {
resolve(JSON.parse(stdout));
} catch (e) {
reject("Failed to parse Python output: " + stdout);
}
});
});
}
app.post('/api/:mode', async (req, res) => {
try {
const result = await runPython(req.params.mode, req.body);
res.json(result);
} catch (error) {
res.status(500).json({ error: error.toString() });
}
});
// Serve static assets from build (for production)
if (fs.existsSync(path.join(__dirname, 'dist'))) {
app.use(express.static(path.join(__dirname, 'dist')));
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
}
app.listen(port, () => {
console.log(`Content Engine Server running on port ${port}`);
});