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:
@@ -0,0 +1,159 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useSiteDesign } from '../../state/SiteDesignContext';
|
||||
|
||||
interface HeadCodeModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const HeadCodeModal: React.FC<HeadCodeModalProps> = ({ open, onClose }) => {
|
||||
const { design, updateDesign } = useSiteDesign();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div style={backdropStyle} onClick={onClose}>
|
||||
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div style={modalHeaderStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<i className="fa fa-code" style={{ color: 'var(--color-accent)', fontSize: 16 }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--color-text)' }}>
|
||||
Custom Head Code
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', marginTop: 2 }}>
|
||||
Add tracking scripts, meta tags, or custom CSS to your site's <head> section.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} style={closeButtonStyle}>
|
||||
<i className="fa fa-times" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: 20, flex: 1, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div style={{
|
||||
padding: '10px 14px',
|
||||
background: 'rgba(59,130,246,0.08)',
|
||||
border: '1px solid rgba(59,130,246,0.2)',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-muted)',
|
||||
lineHeight: 1.5,
|
||||
}}>
|
||||
<i className="fa fa-info-circle" style={{ color: 'var(--color-accent)', marginRight: 6 }} />
|
||||
Code added here will be injected into the <code style={{ background: 'rgba(255,255,255,0.08)', padding: '1px 4px', borderRadius: 3, fontSize: 11 }}><head></code> of every page on your site. Use it for analytics, custom fonts, or global CSS.
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={design.headCode || ''}
|
||||
onChange={(e) => updateDesign({ headCode: e.target.value })}
|
||||
placeholder={"<!-- Google Analytics -->\n<script async src=\"https://...\"></script>\n\n<!-- Custom Fonts -->\n<link href=\"https://fonts.googleapis.com/...\" rel=\"stylesheet\">\n\n<style>\n /* Global CSS overrides */\n body { }\n</style>"}
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 300,
|
||||
padding: 14,
|
||||
background: '#0d0d0f',
|
||||
color: '#e4e4e7',
|
||||
border: '1px solid #3f3f46',
|
||||
borderRadius: 8,
|
||||
fontFamily: 'Source Code Pro, Consolas, monospace',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
resize: 'vertical',
|
||||
outline: 'none',
|
||||
tabSize: 2,
|
||||
}}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div style={{
|
||||
padding: '12px 20px',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 8,
|
||||
}}>
|
||||
<button onClick={onClose} style={doneButtonStyle}>
|
||||
Done
|
||||
</button>
|
||||
</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: 700,
|
||||
maxHeight: '80vh',
|
||||
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',
|
||||
background: 'none',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 6,
|
||||
color: 'var(--color-text-muted)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
const doneButtonStyle: React.CSSProperties = {
|
||||
padding: '8px 24px',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
background: 'var(--color-accent)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
};
|
||||
@@ -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">
|
||||
✕
|
||||
</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)',
|
||||
};
|
||||
@@ -0,0 +1,274 @@
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { useEditorConfig } from '../../state/EditorConfigContext';
|
||||
import { useWhpApi } from '../../hooks/useWhpApi';
|
||||
import { usePages } from '../../state/PageContext';
|
||||
import { DeviceMode } from '../../types';
|
||||
import { TemplateModal } from './TemplateModal';
|
||||
import { HeadCodeModal } from './HeadCodeModal';
|
||||
|
||||
interface TopBarProps {
|
||||
device: DeviceMode;
|
||||
onDeviceChange: (device: DeviceMode) => void;
|
||||
}
|
||||
|
||||
export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
|
||||
const { whpConfig, isWHP } = useEditorConfig();
|
||||
const { actions, query, canUndo, canRedo } = useEditor((_state, query) => ({
|
||||
canUndo: query.history.canUndo(),
|
||||
canRedo: query.history.canRedo(),
|
||||
}));
|
||||
const { save, publish, load } = useWhpApi();
|
||||
const { headerPage, footerPage } = usePages();
|
||||
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
const [publishStatus, setPublishStatus] = useState<'idle' | 'publishing' | 'published' | 'error'>('idle');
|
||||
const [isDraft, setIsDraft] = useState(false);
|
||||
const [templateModalOpen, setTemplateModalOpen] = useState(false);
|
||||
const [headCodeModalOpen, setHeadCodeModalOpen] = useState(false);
|
||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const publishTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const hasLoadedRef = useRef(false);
|
||||
|
||||
// Load saved state on mount
|
||||
useEffect(() => {
|
||||
if (!isWHP || hasLoadedRef.current) return;
|
||||
hasLoadedRef.current = true;
|
||||
|
||||
load().catch((e) => {
|
||||
console.warn('Failed to load project from WHP API:', e);
|
||||
});
|
||||
}, [isWHP, load]);
|
||||
|
||||
// Auto-save every 30 seconds
|
||||
useEffect(() => {
|
||||
if (!isWHP) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
save()
|
||||
.then((result) => {
|
||||
if (result?.success) {
|
||||
setSaveStatus('saved');
|
||||
setIsDraft(true);
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 2000);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Silent fail for auto-save
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isWHP, save]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaveStatus('saving');
|
||||
try {
|
||||
const result = await save();
|
||||
if (result?.success) {
|
||||
setSaveStatus('saved');
|
||||
setIsDraft(true);
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 2500);
|
||||
} else {
|
||||
setSaveStatus('error');
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 3000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Save failed:', e);
|
||||
setSaveStatus('error');
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = setTimeout(() => setSaveStatus('idle'), 3000);
|
||||
}
|
||||
}, [save]);
|
||||
|
||||
const handlePublish = useCallback(async () => {
|
||||
setPublishStatus('publishing');
|
||||
try {
|
||||
const result = await publish();
|
||||
if (result?.success) {
|
||||
setPublishStatus('published');
|
||||
setIsDraft(false);
|
||||
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
|
||||
publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000);
|
||||
} else {
|
||||
setPublishStatus('error');
|
||||
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
|
||||
publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Publish failed:', e);
|
||||
setPublishStatus('error');
|
||||
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
|
||||
publishTimeoutRef.current = setTimeout(() => setPublishStatus('idle'), 3000);
|
||||
}
|
||||
}, [publish]);
|
||||
|
||||
// Cleanup timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
||||
if (publishTimeoutRef.current) clearTimeout(publishTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<nav className="topbar">
|
||||
<div className="topbar-left">
|
||||
{isWHP && (
|
||||
<a href={whpConfig!.backUrl} className="topbar-btn back-btn">
|
||||
<i className="fa fa-arrow-left" /> Back to Panel
|
||||
</a>
|
||||
)}
|
||||
<span className="topbar-title">Site Builder</span>
|
||||
{isWHP && (
|
||||
<span className="topbar-domain">{whpConfig!.siteDomain}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="topbar-center">
|
||||
<div className="device-switcher">
|
||||
{(['desktop', 'tablet', 'mobile'] as DeviceMode[]).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
className={`device-btn ${device === d ? 'active' : ''}`}
|
||||
onClick={() => onDeviceChange(d)}
|
||||
title={d.charAt(0).toUpperCase() + d.slice(1)}
|
||||
>
|
||||
<i className={`fa ${d === 'desktop' ? 'fa-desktop' : d === 'tablet' ? 'fa-tablet' : 'fa-mobile'}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="topbar-right">
|
||||
<button className="topbar-btn" onClick={() => actions.history.undo()} disabled={!canUndo} title="Undo">
|
||||
<i className="fa fa-undo" />
|
||||
</button>
|
||||
<button className="topbar-btn" onClick={() => actions.history.redo()} disabled={!canRedo} title="Redo">
|
||||
<i className="fa fa-repeat" />
|
||||
</button>
|
||||
<span className="topbar-divider" />
|
||||
<button className="topbar-btn" title="Templates" onClick={() => setTemplateModalOpen(true)}>
|
||||
<i className="fa fa-th-large" /> Templates
|
||||
</button>
|
||||
<button className="topbar-btn" title="Custom Head Code" onClick={() => setHeadCodeModalOpen(true)}>
|
||||
<i className="fa fa-code" /> Code
|
||||
</button>
|
||||
<button className="topbar-btn" title="Preview" onClick={() => {
|
||||
try {
|
||||
const serialized = query.serialize();
|
||||
import('../../utils/html-export').then(({ exportToHtml, exportBodyHtml }) => {
|
||||
// Get header HTML
|
||||
let headerHtml = '';
|
||||
try {
|
||||
if (headerPage.craftState) {
|
||||
headerHtml = exportBodyHtml(headerPage.craftState).html;
|
||||
}
|
||||
} catch (e) { console.warn('Header export failed:', e); }
|
||||
|
||||
// Get page body HTML
|
||||
const bodyResult = exportBodyHtml(serialized);
|
||||
const bodyHtml = bodyResult.html;
|
||||
|
||||
// Get footer HTML
|
||||
let footerHtml = '';
|
||||
try {
|
||||
if (footerPage.craftState) {
|
||||
footerHtml = exportBodyHtml(footerPage.craftState).html;
|
||||
}
|
||||
} catch (e) { console.warn('Footer export failed:', e); }
|
||||
|
||||
// Compose full page: header + body + footer
|
||||
const composedBody = headerHtml + bodyHtml + footerHtml;
|
||||
const result = exportToHtml(serialized, {
|
||||
title: whpConfig?.siteName || 'Preview',
|
||||
includeFonts: true,
|
||||
});
|
||||
|
||||
// Replace the body in the full document with our composed version
|
||||
let html = result.html;
|
||||
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
||||
if (bodyMatch) {
|
||||
html = html.replace(bodyMatch[1], composedBody);
|
||||
}
|
||||
|
||||
// Make proxy URLs absolute so they work from the blob: context
|
||||
const origin = window.location.origin;
|
||||
html = html.replace(/src="\/api\//g, `src="${origin}/api/`);
|
||||
html = html.replace(/url\('\/api\//g, `url('${origin}/api/`);
|
||||
const blob = new Blob([html], { type: 'text/html' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Preview failed:', e);
|
||||
}
|
||||
}}>
|
||||
<i className="fa fa-eye" /> Preview
|
||||
</button>
|
||||
|
||||
{/* Draft/Published status badge */}
|
||||
{isWHP && isDraft && publishStatus !== 'published' && (
|
||||
<span className="publish-badge draft">
|
||||
<i className="fa fa-pencil" /> Draft
|
||||
</span>
|
||||
)}
|
||||
{publishStatus === 'published' && (
|
||||
<span className="publish-badge published">
|
||||
<i className="fa fa-check-circle" /> Published
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Save status indicator */}
|
||||
{saveStatus === 'saved' && (
|
||||
<span className="save-indicator saved">
|
||||
<i className="fa fa-check" /> Saved!
|
||||
</span>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<span className="save-indicator error">
|
||||
<i className="fa fa-exclamation-triangle" /> Save Error
|
||||
</span>
|
||||
)}
|
||||
{publishStatus === 'error' && (
|
||||
<span className="save-indicator error">
|
||||
<i className="fa fa-exclamation-triangle" /> Publish Error
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="topbar-btn primary"
|
||||
onClick={handleSave}
|
||||
disabled={saveStatus === 'saving'}
|
||||
title="Save Draft"
|
||||
>
|
||||
{saveStatus === 'saving' ? (
|
||||
<><i className="fa fa-spinner fa-spin" /> Saving...</>
|
||||
) : (
|
||||
<><i className="fa fa-save" /> Save</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isWHP && (
|
||||
<button
|
||||
className="topbar-btn publish"
|
||||
onClick={handlePublish}
|
||||
disabled={publishStatus === 'publishing' || saveStatus === 'saving'}
|
||||
title="Publish to live site"
|
||||
>
|
||||
{publishStatus === 'publishing' ? (
|
||||
<><i className="fa fa-spinner fa-spin" /> Publishing...</>
|
||||
) : (
|
||||
<><i className="fa fa-globe" /> Publish</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
|
||||
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user