Add Craft.js site builder (v2) - complete rebuild from GrapesJS

Rebuilt the visual site builder from scratch using Craft.js, React 18,
and TypeScript. The new editor renders directly in the DOM (no iframe),
supports 40+ components, multi-page with shared header/footer, 16
templates, full-spectrum color/gradient controls, custom head code
injection, save/publish workflow, and auto-save.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 18:31:16 -07:00
parent b511a6684d
commit 91a6b6f34b
103 changed files with 26296 additions and 0 deletions
+607
View File
@@ -0,0 +1,607 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { useEditor } from '@craftjs/core';
import { usePages } from '../../state/PageContext';
import { useSiteDesign } from '../../state/SiteDesignContext';
import {
allTemplates,
TemplateDefinition,
TemplateComponent,
TemplateCategory,
} from '../../templates';
import { componentResolver } from '../../components/resolver';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface TemplateModalProps {
open: boolean;
onClose: () => void;
}
type FilterTab = 'all' | TemplateCategory;
const TABS: { label: string; value: FilterTab }[] = [
{ label: 'All', value: 'all' },
{ label: 'Business', value: 'business' },
{ label: 'Creative', value: 'creative' },
{ label: 'Personal', value: 'personal' },
{ label: 'Community', value: 'community' },
];
const CATEGORY_COLORS: Record<TemplateCategory, string> = {
business: '#3b82f6',
creative: '#a855f7',
personal: '#f59e0b',
community: '#10b981',
};
// ---------------------------------------------------------------------------
// Modal Component
// ---------------------------------------------------------------------------
export const TemplateModal: React.FC<TemplateModalProps> = ({ open, onClose }) => {
const { actions, query } = useEditor();
const { pages, addPage, switchPage, deletePage, editHeader, editFooter } = usePages();
const { updateDesign } = useSiteDesign();
const [activeTab, setActiveTab] = useState<FilterTab>('all');
const [confirmTemplate, setConfirmTemplate] = useState<TemplateDefinition | null>(null);
const [applyDesign, setApplyDesign] = useState(true);
const [loading, setLoading] = useState(false);
// Close on Escape key
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (confirmTemplate) setConfirmTemplate(null);
else onClose();
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [open, confirmTemplate, onClose]);
// Filter templates by category
const filtered = useMemo(() => {
if (activeTab === 'all') return allTemplates;
return allTemplates.filter((t) => t.category === activeTab);
}, [activeTab]);
// Resolve a TemplateComponent type name to its React component
const resolverMap = componentResolver as Record<string, React.ComponentType<any>>;
/**
* Add all components from a template definition onto the current (empty) canvas ROOT.
* Uses Craft.js parseReactElement + addNodeTree which correctly builds valid node structures.
*/
const addTemplateComponents = useCallback(
(components: TemplateComponent[]) => {
for (const comp of components) {
const Component = resolverMap[comp.type];
if (!Component) {
console.warn(`Template references unknown component type: ${comp.type}`);
continue;
}
const element = React.createElement(Component, comp.props);
const tree = query.parseReactElement(element).toNodeTree();
actions.addNodeTree(tree, 'ROOT');
}
},
[query, actions, resolverMap],
);
/**
* Clear the current canvas by deleting all children of ROOT.
*/
const clearCanvas = useCallback(() => {
try {
const rootNode = query.node('ROOT').get();
const childIds = [...(rootNode.data.nodes || [])];
childIds.forEach((id) => {
try {
actions.delete(id);
} catch {
// Node may already be removed
}
});
} catch {
// ROOT doesn't exist yet or is empty -- that's fine
}
}, [query, actions]);
/**
* After all pages are loaded, apply header and footer template content.
* Switches to header/footer editing mode, clears, adds components, then returns to firstPageId.
*/
const applyHeaderFooter = useCallback(
(tpl: TemplateDefinition, firstPageId: string) => {
const hasHeader = tpl.header?.components?.length > 0;
const hasFooter = tpl.footer?.components?.length > 0;
if (!hasHeader && !hasFooter) {
setLoading(false);
return;
}
const applyZone = (
switchFn: () => void,
components: TemplateComponent[],
next: () => void,
) => {
switchFn();
setTimeout(() => {
try {
clearCanvas();
setTimeout(() => {
try {
addTemplateComponents(components);
} catch (e) {
console.warn('Failed to add zone components:', e);
}
setTimeout(next, 30);
}, 20);
} catch (e) {
console.warn('Failed to clear zone:', e);
setTimeout(next, 30);
}
}, 30);
};
const finish = () => {
// Switch back to the first page
switchPage(firstPageId);
setTimeout(() => setLoading(false), 30);
};
const doFooter = () => {
if (hasFooter) {
applyZone(editFooter, tpl.footer.components, finish);
} else {
finish();
}
};
if (hasHeader) {
applyZone(editHeader, tpl.header.components, doFooter);
} else {
doFooter();
}
},
[clearCanvas, addTemplateComponents, switchPage, editHeader, editFooter],
);
// Load the selected template
const handleLoad = useCallback(() => {
if (!confirmTemplate) return;
setLoading(true);
const tpl = confirmTemplate;
try {
// 1. Optionally apply design tokens
if (applyDesign && tpl.design) {
updateDesign(tpl.design);
}
// 2. Remove all pages except the first
const currentPages = [...pages];
const keepId = currentPages[0]?.id;
for (let i = currentPages.length - 1; i >= 1; i--) {
deletePage(currentPages[i].id);
}
// 3. Clear the current canvas and add the first template page's components
clearCanvas();
// Use a short delay to let the clear settle before adding new nodes
setTimeout(() => {
try {
const firstPage = tpl.pages[0];
addTemplateComponents(firstPage.content.components);
// 4. Add remaining pages (if multi-page template)
const finishPages = () => {
// 5. Apply header and footer template content
if (keepId) {
applyHeaderFooter(tpl, keepId);
} else {
setLoading(false);
}
};
if (tpl.pages.length > 1) {
let pageIndex = 1;
const addNextPage = () => {
if (pageIndex >= tpl.pages.length) {
// Switch back to first page, then apply header/footer
if (keepId) switchPage(keepId);
setTimeout(finishPages, 30);
return;
}
const pageDef = tpl.pages[pageIndex];
addPage(pageDef.name, pageDef.slug);
// After addPage, the new page is active with an empty canvas.
// Give a tick for Craft.js to settle, then add components.
setTimeout(() => {
try {
addTemplateComponents(pageDef.content.components);
} catch (e) {
console.warn('Failed to add components for page', pageDef.name, e);
}
pageIndex++;
setTimeout(addNextPage, 30);
}, 30);
};
setTimeout(addNextPage, 30);
} else {
finishPages();
}
} catch (e) {
console.error('Failed to load template:', e);
setLoading(false);
}
}, 20);
} catch (e) {
console.error('Failed to load template:', e);
setLoading(false);
}
setConfirmTemplate(null);
onClose();
}, [confirmTemplate, applyDesign, query, actions, pages, addPage, switchPage, deletePage, updateDesign, addTemplateComponents, clearCanvas, applyHeaderFooter, onClose]);
// Close on backdrop click
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
if (e.target === e.currentTarget) {
if (confirmTemplate) {
setConfirmTemplate(null);
} else {
onClose();
}
}
},
[confirmTemplate, onClose],
);
if (!open) return null;
return (
<div style={backdropStyle} onClick={handleBackdropClick}>
<div style={modalStyle}>
{/* Header */}
<div style={modalHeaderStyle}>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#e4e4e7' }}>
Templates
</h2>
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#71717a' }}>
Choose a template to get started quickly
</p>
</div>
<button onClick={onClose} style={closeButtonStyle} title="Close">
&#10005;
</button>
</div>
{/* Filter Tabs */}
<div style={tabBarStyle}>
{TABS.map((tab) => (
<button
key={tab.value}
onClick={() => setActiveTab(tab.value)}
style={{
...tabStyle,
...(activeTab === tab.value ? tabActiveStyle : {}),
}}
>
{tab.label}
</button>
))}
</div>
{/* Template Grid */}
<div style={gridContainerStyle}>
<div style={gridStyle}>
{filtered.map((tpl) => (
<TemplateCard
key={tpl.id}
template={tpl}
onSelect={() => setConfirmTemplate(tpl)}
/>
))}
{filtered.length === 0 && (
<div style={{ gridColumn: '1 / -1', textAlign: 'center', padding: 40, color: '#71717a' }}>
No templates in this category.
</div>
)}
</div>
</div>
{/* Confirmation Dialog */}
{confirmTemplate && (
<div style={confirmOverlayStyle} onClick={() => setConfirmTemplate(null)}>
<div style={confirmDialogStyle} onClick={(e) => e.stopPropagation()}>
<h3 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 700, color: '#e4e4e7' }}>
Load "{confirmTemplate.name}"?
</h3>
<p style={{ margin: '0 0 20px', fontSize: 13, color: '#a1a1aa' }}>
This will replace your current content with the template pages and components.
</p>
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
fontSize: 13,
color: '#e4e4e7',
cursor: 'pointer',
marginBottom: 24,
padding: '10px 12px',
backgroundColor: 'var(--color-bg-elevated)',
borderRadius: 6,
border: '1px solid var(--color-border)',
}}
>
<input
type="checkbox"
checked={applyDesign}
onChange={(e) => setApplyDesign(e.target.checked)}
style={{ width: 16, height: 16, accentColor: '#3b82f6', cursor: 'pointer' }}
/>
Apply template colors and fonts to site design
</label>
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button
onClick={() => setConfirmTemplate(null)}
style={{
padding: '8px 20px',
fontSize: 13,
fontWeight: 600,
color: '#a1a1aa',
background: 'var(--color-bg-base)',
border: '1px solid var(--color-border)',
borderRadius: 6,
cursor: 'pointer',
}}
>
Cancel
</button>
<button
onClick={handleLoad}
disabled={loading}
style={{
padding: '8px 24px',
fontSize: 13,
fontWeight: 600,
color: '#ffffff',
background: '#3b82f6',
border: 'none',
borderRadius: 6,
cursor: loading ? 'wait' : 'pointer',
opacity: loading ? 0.7 : 1,
}}
>
{loading ? 'Loading...' : 'Load Template'}
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// TemplateCard sub-component
// ---------------------------------------------------------------------------
const TemplateCard: React.FC<{
template: TemplateDefinition;
onSelect: () => void;
}> = ({ template, onSelect }) => {
const [hovered, setHovered] = useState(false);
return (
<div
onClick={onSelect}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
borderRadius: 8,
border: `1px solid ${hovered ? 'var(--color-accent)' : 'var(--color-border)'}`,
backgroundColor: hovered ? 'var(--color-bg-elevated)' : 'var(--color-bg-surface)',
cursor: 'pointer',
overflow: 'hidden',
transition: 'all 0.2s ease',
transform: hovered ? 'translateY(-2px)' : 'none',
boxShadow: hovered ? '0 4px 12px rgba(0,0,0,0.3)' : 'none',
}}
>
{/* Thumbnail */}
<div
style={{
width: '100%',
height: 120,
backgroundColor: '#1a1a24',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}}
>
<img
src={template.thumbnail}
alt={template.name}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
/>
</div>
{/* Info */}
<div style={{ padding: '12px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: '#e4e4e7' }}>
{template.name}
</span>
<span
style={{
fontSize: 10,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.5px',
color: CATEGORY_COLORS[template.category],
backgroundColor: `${CATEGORY_COLORS[template.category]}18`,
padding: '2px 6px',
borderRadius: 4,
}}
>
{template.category}
</span>
</div>
<p style={{ margin: 0, fontSize: 11, color: '#71717a', lineHeight: 1.4 }}>
{template.description}
</p>
<div style={{ marginTop: 8, display: 'flex', gap: 8, alignItems: 'center' }}>
<span
style={{
fontSize: 10,
color: '#52525b',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
<i className="fa fa-file-o" style={{ fontSize: 9 }} />
{template.isMultiPage ? `${template.pages.length} pages` : 'Single page'}
</span>
</div>
</div>
</div>
);
};
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const backdropStyle: React.CSSProperties = {
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.65)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10000,
};
const modalStyle: React.CSSProperties = {
width: '90vw',
maxWidth: 900,
maxHeight: '85vh',
backgroundColor: 'var(--color-bg-surface)',
borderRadius: 12,
border: '1px solid var(--color-border)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
};
const modalHeaderStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '16px 20px',
borderBottom: '1px solid var(--color-border)',
flexShrink: 0,
};
const closeButtonStyle: React.CSSProperties = {
width: 32,
height: 32,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 14,
color: '#71717a',
background: 'transparent',
border: 'none',
borderRadius: 6,
cursor: 'pointer',
};
const tabBarStyle: React.CSSProperties = {
display: 'flex',
gap: 4,
padding: '12px 20px',
borderBottom: '1px solid var(--color-border)',
flexShrink: 0,
overflowX: 'auto',
};
const tabStyle: React.CSSProperties = {
padding: '6px 16px',
fontSize: 12,
fontWeight: 500,
color: '#71717a',
background: 'transparent',
border: '1px solid transparent',
borderRadius: 6,
cursor: 'pointer',
whiteSpace: 'nowrap',
transition: 'all 0.15s ease',
};
const tabActiveStyle: React.CSSProperties = {
color: '#e4e4e7',
background: 'var(--color-bg-elevated)',
borderColor: 'var(--color-border)',
};
const gridContainerStyle: React.CSSProperties = {
flex: 1,
overflowY: 'auto',
padding: '16px 20px 20px',
};
const gridStyle: React.CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))',
gap: 16,
};
const confirmOverlayStyle: React.CSSProperties = {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
zIndex: 1,
};
const confirmDialogStyle: React.CSSProperties = {
width: '90%',
maxWidth: 420,
padding: '24px',
backgroundColor: 'var(--color-bg-surface)',
borderRadius: 10,
border: '1px solid var(--color-border)',
boxShadow: '0 8px 32px rgba(0,0,0,0.4)',
};