feat(content): implement Content Engine MVP (v1.0) with GTM integration
This commit is contained in:
509
content-engine/frontend/src/App.tsx
Normal file
509
content-engine/frontend/src/App.tsx
Normal 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 →</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>
|
||||
);
|
||||
}
|
||||
8
content-engine/frontend/src/index.css
Normal file
8
content-engine/frontend/src/index.css
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
@apply bg-slate-900 text-slate-100;
|
||||
}
|
||||
11
content-engine/frontend/src/main.tsx
Normal file
11
content-engine/frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
Reference in New Issue
Block a user