530 lines
22 KiB
TypeScript
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 →</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>
|
|
);
|
|
}
|