Files
Brancheneinstufung2/content-engine/frontend/src/App.tsx

530 lines
22 KiB
TypeScript

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, onUpdate }: { project: ContentProject, setLoading: (b: boolean) => void, onUpdate: () => void }) {
const [keywords, setKeywords] = useState<string[]>(project.seo_strategy?.seed_keywords || []);
const generateKeywords = async () => {
setLoading(true);
try {
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();
if (data.error) {
alert(`Error: ${data.error}`);
} else {
setKeywords(data.keywords || []);
onUpdate();
}
} catch (err) { console.error(err); alert("Network Error"); }
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, onUpdate }: { project: ContentProject, setLoading: (b: boolean) => void, onUpdate: () => void }) {
const [sections, setSections] = useState<ContentAsset[]>(project.assets || []);
const [editingContent, setEditingContent] = useState<{ [key: string]: string }>({});
useEffect(() => {
// When project updates (e.g. via onUpdate->Parent Refresh), update local sections
if (project.assets) {
setSections(project.assets);
const newEditing: { [key: string]: string } = {};
project.assets.forEach(s => {
newEditing[s.section_key] = s.content;
});
setEditingContent(newEditing);
}
}, [project.assets]);
const generateSection = async (key: string) => {
setLoading(true);
try {
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();
if (data.error) {
alert(`Error: ${data.error}`);
return;
}
onUpdate(); // Refresh parent to get fresh data from DB
} catch (err) { console.error(err); alert("Network Error"); }
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 {
await fetch('api/generate_section', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
projectId: project.id,
sectionKey: key,
manualContent: content
})
});
onUpdate(); // Refresh parent
alert("Saved successfully!");
} catch (err) { console.error(err); alert("Network Error"); }
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, onRefresh }: { project: ContentProject, onBack: () => void, setLoading: (b: boolean) => void, onRefresh: () => 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} onUpdate={onRefresh} />}
{activeTab === 'WEBSITE' && <WebsiteBuilder project={project} setLoading={setLoading} onUpdate={onRefresh} />}
{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 {
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 {
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 {
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.error) {
alert(`Import Error: ${data.error}`);
} else if (data.id) {
await fetchContentProjects();
setView('LIST');
}
} catch (err) { console.error(err); alert("Network Error"); }
setLoading(false);
};
const loadProject = async (id: number) => {
setLoading(true);
try {
const res = await fetch('api/load_project', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ projectId: id })
});
const data = await res.json();
if (data.error) {
alert(`Load Error: ${data.error}`);
} else {
setSelectedProject(data);
setView('DETAILS');
}
} catch (err) { console.error(err); alert("Network Error"); }
setLoading(false);
};
// Wrapper for refreshing data inside Dashboard
const handleRefreshProject = async () => {
if (selectedProject) {
await loadProject(selectedProject.id);
}
};
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}
onRefresh={handleRefreshProject}
/>
)}
</main>
</div>
);
}