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
@@ -0,0 +1,206 @@
import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from './Container';
import { cssPropsToString } from '../../utils/style-helpers';
interface BackgroundSectionProps {
bgImage?: string;
bgColor?: string;
overlayColor?: string;
overlayOpacity?: number;
innerMaxWidth?: string;
style?: CSSProperties;
children?: React.ReactNode;
}
export const BackgroundSection: UserComponent<BackgroundSectionProps> = ({
bgImage = '',
bgColor = '#1e293b',
overlayColor = '#000000',
overlayOpacity = 0.4,
innerMaxWidth = '1200px',
style = {},
}) => {
const { connectors: { connect, drag } } = useNode();
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
position: 'relative',
width: '100%',
minHeight: '200px',
backgroundColor: bgColor,
backgroundImage: bgImage ? `url(${bgImage})` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
...style,
}}
>
{/* Overlay */}
<div
style={{
position: 'absolute',
inset: 0,
backgroundColor: overlayColor,
opacity: overlayOpacity,
pointerEvents: 'none',
}}
/>
{/* Content */}
<Element
id="bg-section-inner"
is={Container}
canvas
style={{
position: 'relative',
zIndex: 1,
maxWidth: innerMaxWidth,
margin: '0 auto',
padding: '60px 20px',
}}
tag="div"
/>
</section>
);
};
/* ---------- Settings panel ---------- */
const BackgroundSectionSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as BackgroundSectionProps,
}));
const bgColorPresets = ['#1e293b', '#0f172a', '#18181b', '#1e3a5f', '#312e81', '#064e3b', '#7f1d1d', '#ffffff'];
const overlayPresets = ['#000000', '#1e293b', '#0f172a', '#312e81', '#064e3b', '#7f1d1d'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Image URL</label>
<input
type="text"
value={props.bgImage || ''}
onChange={(e) => setProp((p: BackgroundSectionProps) => { p.bgImage = e.target.value; })}
placeholder="https://... or /storage/assets/..."
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: BackgroundSectionProps) => { p.bgColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.bgColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Overlay Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{overlayPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: BackgroundSectionProps) => { p.overlayColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.overlayColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>
Overlay Opacity: {Math.round((props.overlayOpacity ?? 0.4) * 100)}%
</label>
<input
type="range"
min={0}
max={100}
value={Math.round((props.overlayOpacity ?? 0.4) * 100)}
onChange={(e) => setProp((p: BackgroundSectionProps) => { p.overlayOpacity = parseInt(e.target.value, 10) / 100; })}
style={{ width: '100%' }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Inner Max Width</label>
<input
type="text"
value={props.innerMaxWidth || '1200px'}
onChange={(e) => setProp((p: BackgroundSectionProps) => { p.innerMaxWidth = e.target.value; })}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11 }}
/>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
BackgroundSection.craft = {
displayName: 'Background Section',
props: {
bgImage: '',
bgColor: '#1e293b',
overlayColor: '#000000',
overlayOpacity: 0.4,
innerMaxWidth: '1200px',
style: { padding: '0' },
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: BackgroundSectionSettings,
},
};
/* ---------- HTML export ---------- */
(BackgroundSection as any).toHtml = (props: BackgroundSectionProps, childrenHtml: string) => {
const outerStyle = cssPropsToString({
position: 'relative',
width: '100%',
minHeight: '200px',
backgroundColor: props.bgColor || '#1e293b',
backgroundImage: props.bgImage ? `url(${props.bgImage})` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
...props.style,
});
const overlayStyle = cssPropsToString({
position: 'absolute',
inset: '0',
backgroundColor: props.overlayColor || '#000000',
opacity: String(props.overlayOpacity ?? 0.4),
pointerEvents: 'none',
});
const innerStyle = cssPropsToString({
position: 'relative',
zIndex: '1',
maxWidth: props.innerMaxWidth || '1200px',
margin: '0 auto',
padding: '60px 20px',
});
return {
html: `<section${outerStyle ? ` style="${outerStyle}"` : ''}><div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></section>`,
};
};
@@ -0,0 +1,298 @@
import React, { CSSProperties, useState } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from './Container';
import { cssPropsToString } from '../../utils/style-helpers';
type SplitOption =
| '100'
| '50-50' | '30-70' | '70-30' | '40-60' | '60-40'
| '33-33-33' | '25-50-25'
| '25-25-25-25'
| '20-20-20-20-20'
| '16-16-16-16-16-16'
| 'equal';
interface ColumnLayoutProps {
columns?: number;
split?: SplitOption;
gap?: string;
style?: CSSProperties;
children?: React.ReactNode;
}
const splitToWidths: Record<string, string[]> = {
'100': ['100%'],
'50-50': ['50%', '50%'],
'30-70': ['30%', '70%'],
'70-30': ['70%', '30%'],
'40-60': ['40%', '60%'],
'60-40': ['60%', '40%'],
'33-33-33': ['33.333%', '33.333%', '33.333%'],
'25-50-25': ['25%', '50%', '25%'],
'25-25-25-25': ['25%', '25%', '25%', '25%'],
'20-20-20-20-20': ['20%', '20%', '20%', '20%', '20%'],
'16-16-16-16-16-16': ['16.666%', '16.666%', '16.666%', '16.666%', '16.666%', '16.666%'],
};
function getWidths(split: SplitOption, columns: number): string[] {
// Check predefined splits first
if (split !== 'equal') {
const defined = splitToWidths[split];
if (defined && defined.length === columns) return defined;
}
// Try parsing custom split string (e.g., "35-65" or "25-50-25")
if (split && split !== 'equal' && split.includes('-')) {
const parts = split.split('-').map(Number);
if (parts.length === columns && parts.every(n => !isNaN(n) && n > 0)) {
return parts.map(n => `${n}%`);
}
}
// Fallback: equal widths
const w = `${(100 / columns).toFixed(3)}%`;
return Array.from({ length: columns }, () => w);
}
export const ColumnLayout: UserComponent<ColumnLayoutProps> = ({
columns = 2,
split = '50-50',
gap = '16px',
style = {},
}) => {
const { connectors: { connect, drag } } = useNode();
const widths = getWidths(split, columns);
return (
<div
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
display: 'flex',
flexWrap: 'wrap',
gap,
width: '100%',
minHeight: '60px',
...style,
}}
>
{widths.map((w, i) => (
<Element
key={`col-${i}`}
id={`col-${i}`}
is={Container}
canvas
custom={{ className: 'craft-column' }}
style={{ flex: `0 0 calc(${w} - ${gap})`, minHeight: '60px', padding: '8px' }}
tag="div"
/>
))}
</div>
);
};
/* ---------- Settings panel ---------- */
const ColumnLayoutSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as ColumnLayoutProps,
}));
const [showCustom, setShowCustom] = useState(false);
/* Preset options -- common splits up to 6 columns */
const presetOptions: { columns: number; split: SplitOption; label: string }[] = [
{ columns: 1, split: '100', label: '1 Col' },
{ columns: 2, split: '50-50', label: '2 Equal' },
{ columns: 2, split: '30-70', label: '30/70' },
{ columns: 2, split: '70-30', label: '70/30' },
{ columns: 2, split: '40-60', label: '40/60' },
{ columns: 2, split: '60-40', label: '60/40' },
{ columns: 3, split: '33-33-33', label: '3 Equal' },
{ columns: 3, split: '25-50-25', label: '25/50/25' },
{ columns: 4, split: '25-25-25-25', label: '4 Equal' },
{ columns: 5, split: '20-20-20-20-20', label: '5 Equal' },
{ columns: 6, split: '16-16-16-16-16-16', label: '6 Equal' },
];
const gapPresets = ['0px', '8px', '16px', '24px', '32px'];
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Preset layouts */}
<div>
<label style={labelStyle}>Column Layout</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{presetOptions.map((opt) => (
<button
key={opt.label}
onClick={() => {
setProp((p: ColumnLayoutProps) => { p.columns = opt.columns; p.split = opt.split; });
setShowCustom(false);
}}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.split === opt.split && props.columns === opt.columns ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Custom column count (7-10) */}
<div>
<button
onClick={() => setShowCustom(!showCustom)}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: showCustom ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
width: '100%',
}}
>
{showCustom ? 'Hide Custom' : 'Custom (7-10 columns)'}
</button>
{showCustom && (
<div style={{ marginTop: 8 }}>
<label style={labelStyle}>Number of Columns</label>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input
type="range"
min={1}
max={10}
value={props.columns || 2}
onChange={(e) => {
const cols = parseInt(e.target.value);
setProp((p: ColumnLayoutProps) => { p.columns = cols; p.split = 'equal'; });
}}
style={{ flex: 1 }}
/>
<span style={{ fontSize: 12, color: '#e4e4e7', minWidth: 24, textAlign: 'center' }}>{props.columns || 2}</span>
</div>
</div>
)}
</div>
{/* Gap */}
<div>
<label style={labelStyle}>Gap</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{gapPresets.map((g) => (
<button
key={g}
onClick={() => setProp((p: ColumnLayoutProps) => { p.gap = g; })}
style={{
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.gap === g ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{g}
</button>
))}
</div>
</div>
{/* Individual Column Widths */}
<div>
<label style={labelStyle}>Column Widths (%)</label>
<p style={{ fontSize: 10, color: '#71717a', marginBottom: 6 }}>
Adjust each column's width. Values should roughly total 100%.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{Array.from({ length: props.columns || 2 }).map((_, i) => {
const currentWidths = getWidths(props.split || 'equal', props.columns || 2);
const currentPct = parseFloat(currentWidths[i]) || (100 / (props.columns || 2));
return (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 10, color: '#71717a', minWidth: 40 }}>Col {i + 1}</span>
<input
type="range"
min={10}
max={90}
step={5}
value={Math.round(currentPct)}
onChange={(e) => {
const newPct = parseInt(e.target.value);
const cols = props.columns || 2;
const widths = getWidths(props.split || 'equal', cols).map(w => parseFloat(w));
const oldPct = widths[i];
const diff = newPct - oldPct;
widths[i] = newPct;
// Distribute the difference across other columns proportionally
const others = widths.filter((_, j) => j !== i);
const otherTotal = others.reduce((a, b) => a + b, 0);
if (otherTotal > 0) {
for (let j = 0; j < widths.length; j++) {
if (j !== i) {
widths[j] = widths[j] - (diff * (widths[j] / otherTotal));
if (widths[j] < 5) widths[j] = 5;
}
}
}
// Normalize to 100%
const total = widths.reduce((a, b) => a + b, 0);
const normalized = widths.map(w => ((w / total) * 100).toFixed(1) + '%');
const customSplit = normalized.map(w => parseFloat(w).toFixed(0)).join('-') as SplitOption;
setProp((p: ColumnLayoutProps) => { p.split = customSplit; });
}}
style={{ flex: 1 }}
/>
<span style={{ fontSize: 11, color: '#e4e4e7', minWidth: 35, textAlign: 'right' }}>
{Math.round(currentPct)}%
</span>
</div>
);
})}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
ColumnLayout.craft = {
displayName: 'Columns',
props: {
columns: 2,
split: '50-50',
gap: '16px',
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: ColumnLayoutSettings,
},
};
/* ---------- HTML export ---------- */
(ColumnLayout as any).toHtml = (props: ColumnLayoutProps, childrenHtml: string) => {
const gap = props.gap || '16px';
const outerStyle = cssPropsToString({
display: 'flex',
flexWrap: 'wrap',
gap,
width: '100%',
...props.style,
});
return {
html: `<div${outerStyle ? ` style="${outerStyle}"` : ''}>${childrenHtml}</div>`,
};
};
+324
View File
@@ -0,0 +1,324 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { SettingsTabs } from '../../ui/SettingsTabs';
import { BorderControl } from '../../ui/BorderControl';
import { AdvancedTab } from '../../ui/AdvancedTab';
interface ContainerProps {
style?: CSSProperties;
tag?: 'div' | 'section' | 'article' | 'header' | 'footer' | 'main';
children?: React.ReactNode;
cssId?: string;
cssClass?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
fullWidth?: boolean;
contentWidth?: 'boxed' | 'full';
}
export const Container: UserComponent<ContainerProps> = ({
style = {},
tag = 'div',
children,
fullWidth = false,
contentWidth = 'full',
}) => {
const { connectors: { connect, drag } } = useNode();
const outerStyle: CSSProperties = {
minHeight: '40px',
...style,
...(fullWidth ? { width: '100vw', marginLeft: 'calc(-50vw + 50%)' } : {}),
};
const needsBoxedWrapper = contentWidth === 'boxed';
const el = React.createElement(
tag,
{
ref: (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); },
style: outerStyle,
'data-craft-container': 'true',
},
needsBoxedWrapper
? React.createElement('div', { style: { maxWidth: '1200px', margin: '0 auto' } }, children)
: children,
);
return el;
};
/* ---------- Settings panel ---------- */
const cLabelStyle: React.CSSProperties = {
fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6,
textTransform: 'uppercase', letterSpacing: '0.3px',
};
const cInputStyle: React.CSSProperties = {
width: '100%', padding: '5px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const cPresetBtnStyle = (active: boolean): React.CSSProperties => ({
padding: '3px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46', background: active ? '#3b82f6' : '#27272a', color: active ? '#fff' : '#e4e4e7',
});
const cSwatchStyle = (color: string, active: boolean): React.CSSProperties => ({
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46', backgroundColor: color, cursor: 'pointer',
outline: active ? '2px solid #3b82f6' : 'none', outlineOffset: 1,
});
const cToggleBtnStyle = (active: boolean): React.CSSProperties => ({
flex: 1, padding: '5px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: active ? '#3b82f6' : '#27272a',
color: active ? '#fff' : '#e4e4e7',
fontWeight: active ? 600 : 400,
textAlign: 'center',
});
const ContainerSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as ContainerProps,
}));
const bgColors = ['transparent', '#ffffff', '#f9fafb', '#f1f5f9', '#1f2937', '#111827', '#0f172a', '#3b82f6', '#10b981', '#8b5cf6', '#ec4899', '#f59e0b'];
const gradients = [
{ label: 'None', value: 'none' },
{ label: 'Purple', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
{ label: 'Blue', value: 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)' },
{ label: 'Sunset', value: 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)' },
{ label: 'Dark', value: 'linear-gradient(135deg, #0f172a 0%, #1e3a5f 100%)' },
{ label: 'Green', value: 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)' },
];
const alignPresets = [
{ label: 'Left', value: 'left', icon: 'fa-align-left' },
{ label: 'Center', value: 'center', icon: 'fa-align-center' },
{ label: 'Right', value: 'right', icon: 'fa-align-right' },
];
const currentBg = props.style?.backgroundColor || '';
const currentBgImage = props.style?.backgroundImage || '';
return (
<SettingsTabs
general={
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Tag */}
<div>
<label style={cLabelStyle}>HTML Element</label>
<select
value={props.tag || 'div'}
onChange={(e) => setProp((p: ContainerProps) => { p.tag = e.target.value as ContainerProps['tag']; })}
style={cInputStyle}
>
{['div', 'section', 'article', 'header', 'footer', 'main'].map((t) => (
<option key={t} value={t}>&lt;{t}&gt;</option>
))}
</select>
</div>
{/* Full Width */}
<div>
<label style={{ ...cLabelStyle, display: 'flex', alignItems: 'center', gap: 6, textTransform: 'none', fontWeight: 500, cursor: 'pointer' }}>
<input
type="checkbox"
checked={props.fullWidth || false}
onChange={(e) => setProp((p: ContainerProps) => { p.fullWidth = e.target.checked; })}
/>
Full Width
</label>
<span style={{ fontSize: 10, color: '#71717a', lineHeight: '1.3', display: 'block', marginTop: 2 }}>
Breaks out of parent constraints to fill the viewport width
</span>
</div>
{/* Content Width */}
<div>
<label style={cLabelStyle}>Content Width</label>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => setProp((p: ContainerProps) => { p.contentWidth = 'full'; })}
style={cToggleBtnStyle((props.contentWidth || 'full') === 'full')}
>
Full
</button>
<button
onClick={() => setProp((p: ContainerProps) => { p.contentWidth = 'boxed'; })}
style={cToggleBtnStyle(props.contentWidth === 'boxed')}
>
Boxed (1200px)
</button>
</div>
<span style={{ fontSize: 10, color: '#71717a', lineHeight: '1.3', display: 'block', marginTop: 4 }}>
{props.contentWidth === 'boxed'
? 'Content is centered with a max-width of 1200px'
: 'Content fills the full container width'}
</span>
</div>
</div>
}
style={
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Background Color */}
<div>
<label style={cLabelStyle}>Background Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgColors.map((c) => (
<button key={c} onClick={() => setProp((p: ContainerProps) => { p.style = { ...p.style, backgroundColor: c, backgroundImage: 'none' }; })}
style={cSwatchStyle(c === 'transparent' ? '#fff' : c, currentBg === c)} title={c} />
))}
</div>
</div>
{/* Background Gradient */}
<div>
<label style={cLabelStyle}>Gradient</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{gradients.map((g) => (
<button key={g.value} onClick={() => setProp((p: ContainerProps) => {
p.style = { ...p.style, backgroundImage: g.value === 'none' ? 'none' : g.value, backgroundColor: 'transparent' };
})} style={{
width: 32, height: 24, borderRadius: 4, cursor: 'pointer',
border: currentBgImage === g.value ? '2px solid #3b82f6' : '1px solid #3f3f46',
background: g.value === 'none' ? '#27272a' : g.value,
}} title={g.label} />
))}
</div>
</div>
{/* Background Image */}
<div>
<label style={cLabelStyle}>Background Image</label>
<input type="text" placeholder="Image URL..."
value={(props.style?.backgroundImage || '').replace(/^url\(['"]?|['"]?\)$/g, '')}
onChange={(e) => {
const val = e.target.value.trim();
setProp((p: ContainerProps) => {
p.style = { ...p.style, backgroundImage: val ? `url('${val}')` : 'none', backgroundSize: 'cover', backgroundPosition: 'center' };
});
}}
style={cInputStyle} />
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
{['cover', 'contain', 'auto'].map((s) => (
<button key={s} onClick={() => setProp((p: ContainerProps) => { p.style = { ...p.style, backgroundSize: s }; })}
style={cPresetBtnStyle(props.style?.backgroundSize === s)}>{s}</button>
))}
{['center', 'top', 'bottom'].map((pos) => (
<button key={pos} onClick={() => setProp((p: ContainerProps) => { p.style = { ...p.style, backgroundPosition: pos }; })}
style={cPresetBtnStyle(props.style?.backgroundPosition === pos)}>{pos}</button>
))}
</div>
</div>
{/* Overlay */}
<div>
<label style={cLabelStyle}>Overlay Color</label>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input type="color" value={props.style?.['--overlayColor' as keyof CSSProperties] || '#000000'}
onChange={(e) => setProp((p: ContainerProps) => { p.style = { ...p.style, ['--overlayColor' as keyof CSSProperties]: e.target.value }; })}
style={{ width: 32, height: 24, border: 'none', background: 'none', cursor: 'pointer' }} />
<span style={{ fontSize: 11, color: '#71717a' }}>Overlay (via CSS custom property)</span>
</div>
</div>
{/* Parallax */}
<div>
<label style={{ ...cLabelStyle, display: 'flex', alignItems: 'center', gap: 6, textTransform: 'none', fontWeight: 500 }}>
<input type="checkbox"
checked={props.style?.backgroundAttachment === 'fixed'}
onChange={(e) => setProp((p: ContainerProps) => { p.style = { ...p.style, backgroundAttachment: e.target.checked ? 'fixed' : 'scroll' }; })} />
Parallax Effect
</label>
</div>
{/* Text Alignment */}
<div>
<label style={cLabelStyle}>Content Alignment</label>
<div style={{ display: 'flex', gap: 4 }}>
{alignPresets.map((a) => (
<button key={a.value} onClick={() => setProp((p: ContainerProps) => { p.style = { ...p.style, textAlign: a.value as any }; })}
style={{ ...cPresetBtnStyle(props.style?.textAlign === a.value), flex: 1 }}>
<i className={`fa ${a.icon}`} />
</button>
))}
</div>
</div>
{/* Border */}
<BorderControl
style={props.style || {}}
onChange={(updates) => setProp((p: ContainerProps) => { p.style = { ...p.style, ...updates }; })}
/>
</div>
}
advanced={
<AdvancedTab
style={props.style || {}}
onStyleChange={(updates) => setProp((p: ContainerProps) => { p.style = { ...p.style, ...updates }; })}
showTagSelector
tag={props.tag || 'div'}
onTagChange={(tag) => setProp((p: ContainerProps) => { p.tag = tag as ContainerProps['tag']; })}
cssId={props.cssId || ''}
onCssIdChange={(id) => setProp((p: ContainerProps) => { p.cssId = id; })}
cssClass={props.cssClass || ''}
onCssClassChange={(cls) => setProp((p: ContainerProps) => { p.cssClass = cls; })}
hideOnDesktop={props.hideOnDesktop}
onHideOnDesktopChange={(v) => setProp((p: ContainerProps) => { p.hideOnDesktop = v; })}
hideOnTablet={props.hideOnTablet}
onHideOnTabletChange={(v) => setProp((p: ContainerProps) => { p.hideOnTablet = v; })}
hideOnMobile={props.hideOnMobile}
onHideOnMobileChange={(v) => setProp((p: ContainerProps) => { p.hideOnMobile = v; })}
animation={props.animation}
onAnimationChange={(v) => setProp((p: ContainerProps) => { p.animation = v; })}
animationDelay={props.animationDelay}
onAnimationDelayChange={(v) => setProp((p: ContainerProps) => { p.animationDelay = v; })}
/>
}
/>
);
};
/* ---------- Craft config ---------- */
Container.craft = {
displayName: 'Container',
props: {
style: { padding: '20px', minHeight: '100px' },
tag: 'div',
fullWidth: false,
contentWidth: 'full',
},
rules: {
canDrag: () => true,
canMoveIn: () => true,
canMoveOut: () => true,
},
related: {
settings: ContainerSettings,
},
};
/* ---------- HTML export ---------- */
(Container as any).toHtml = (props: ContainerProps, childrenHtml: string) => {
const tag = props.tag || 'div';
const outerCss: CSSProperties = { ...props.style };
if (props.fullWidth) {
outerCss.width = '100vw';
outerCss.marginLeft = 'calc(-50vw + 50%)';
}
const styleStr = cssPropsToString(outerCss);
if (props.contentWidth === 'boxed') {
const innerStyle = cssPropsToString({ maxWidth: '1200px', margin: '0 auto' });
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></${tag}>` };
}
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</${tag}>` };
};
@@ -0,0 +1,81 @@
import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from './Container';
import { cssPropsToString } from '../../utils/style-helpers';
interface FooterZoneProps {
style?: CSSProperties;
children?: React.ReactNode;
}
export const FooterZone: UserComponent<FooterZoneProps> = ({ style = {}, children }) => {
const { connectors: { connect, drag } } = useNode();
return (
<footer
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
data-zone="footer"
style={{
width: '100%',
minHeight: '50px',
borderTop: '1px solid rgba(148,163,184,0.15)',
...style,
}}
>
<Element id="footer-content" is={Container} canvas tag="div" style={{ padding: '0' }}>
{children}
</Element>
</footer>
);
};
const FooterZoneSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as FooterZoneProps,
}));
const bgPresets = ['#ffffff', '#f9fafb', '#1f2937', '#111827', '#0f172a'];
return (
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
<p style={{ fontSize: 11, color: '#f59e0b', margin: 0 }}>
<strong>Footer Zone</strong> -- This section appears on all pages.
</p>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 }}>Background</label>
<div style={{ display: 'flex', gap: 4 }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: FooterZoneProps) => { p.style = { ...p.style, backgroundColor: c }; })}
style={{ width: 28, height: 28, borderRadius: 4, border: '1px solid #3f3f46', background: c, cursor: 'pointer' }}
/>
))}
</div>
</div>
</div>
);
};
FooterZone.craft = {
displayName: 'Footer Zone',
props: {
style: { backgroundColor: '#0f172a', color: '#94a3b8', padding: '40px 20px', textAlign: 'center' as const },
},
rules: {
canDrag: () => false,
canMoveIn: () => true,
canMoveOut: () => true,
},
related: {
settings: FooterZoneSettings,
},
};
(FooterZone as any).toHtml = (props: FooterZoneProps, childrenHtml: string) => {
const styleStr = cssPropsToString({
width: '100%',
...props.style,
});
return { html: `<footer${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</footer>` };
};
@@ -0,0 +1,81 @@
import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from './Container';
import { cssPropsToString } from '../../utils/style-helpers';
interface HeaderZoneProps {
style?: CSSProperties;
children?: React.ReactNode;
}
export const HeaderZone: UserComponent<HeaderZoneProps> = ({ style = {}, children }) => {
const { connectors: { connect, drag } } = useNode();
return (
<header
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
data-zone="header"
style={{
width: '100%',
minHeight: '50px',
borderBottom: '1px solid rgba(148,163,184,0.15)',
...style,
}}
>
<Element id="header-content" is={Container} canvas tag="div" style={{ padding: '0' }}>
{children}
</Element>
</header>
);
};
const HeaderZoneSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as HeaderZoneProps,
}));
const bgPresets = ['#ffffff', '#f9fafb', '#1f2937', '#111827', '#0f172a'];
return (
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
<p style={{ fontSize: 11, color: '#f59e0b', margin: 0 }}>
<strong>Header Zone</strong> -- This section appears on all pages.
</p>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 }}>Background</label>
<div style={{ display: 'flex', gap: 4 }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: HeaderZoneProps) => { p.style = { ...p.style, backgroundColor: c }; })}
style={{ width: 28, height: 28, borderRadius: 4, border: '1px solid #3f3f46', background: c, cursor: 'pointer' }}
/>
))}
</div>
</div>
</div>
);
};
HeaderZone.craft = {
displayName: 'Header Zone',
props: {
style: { backgroundColor: '#ffffff' },
},
rules: {
canDrag: () => false, // Header stays at the top, can't be moved
canMoveIn: () => true,
canMoveOut: () => true,
},
related: {
settings: HeaderZoneSettings,
},
};
(HeaderZone as any).toHtml = (props: HeaderZoneProps, childrenHtml: string) => {
const styleStr = cssPropsToString({
width: '100%',
...props.style,
});
return { html: `<header${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</header>` };
};
+401
View File
@@ -0,0 +1,401 @@
import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { Container } from './Container';
/* ---------- Shape Divider SVG Paths ---------- */
type DividerShape = 'none' | 'wave' | 'angle' | 'curve' | 'triangle' | 'zigzag';
const DIVIDER_PATHS: Record<Exclude<DividerShape, 'none'>, string> = {
wave: 'M0,0 C150,120 350,0 600,60 C850,120 1050,0 1200,60 L1200,120 L0,120 Z',
angle: 'M0,0 L1200,120 L0,120 Z',
curve: 'M0,0 Q600,140 1200,0 L1200,120 L0,120 Z',
triangle: 'M0,120 L600,0 L1200,120 Z',
zigzag: 'M0,120 L100,40 L200,120 L300,40 L400,120 L500,40 L600,120 L700,40 L800,120 L900,40 L1000,120 L1100,40 L1200,120 Z',
};
const DIVIDER_SHAPES: DividerShape[] = ['none', 'wave', 'angle', 'curve', 'triangle', 'zigzag'];
interface SectionProps {
style?: CSSProperties;
innerMaxWidth?: string;
children?: React.ReactNode;
topDivider?: DividerShape;
topDividerColor?: string;
topDividerHeight?: string;
bottomDivider?: DividerShape;
bottomDividerColor?: string;
bottomDividerHeight?: string;
}
/* ---------- Divider renderer ---------- */
const ShapeDivider: React.FC<{
shape: DividerShape;
color: string;
height: string;
position: 'top' | 'bottom';
}> = ({ shape, color, height, position }) => {
if (!shape || shape === 'none') return null;
const path = DIVIDER_PATHS[shape];
if (!path) return null;
const isTop = position === 'top';
return (
<div
style={{
position: 'absolute',
[position]: 0,
left: 0,
right: 0,
height: height || '50px',
overflow: 'hidden',
lineHeight: 0,
pointerEvents: 'none',
}}
>
<svg
viewBox="0 0 1200 120"
preserveAspectRatio="none"
style={{
width: '100%',
height: '100%',
fill: color || '#ffffff',
transform: isTop ? 'rotate(180deg)' : undefined,
display: 'block',
}}
>
<path d={path} />
</svg>
</div>
);
};
/* ---------- Component ---------- */
export const Section: UserComponent<SectionProps> = ({
style = {},
innerMaxWidth = '1200px',
children,
topDivider = 'none',
topDividerColor = '#ffffff',
topDividerHeight = '50px',
bottomDivider = 'none',
bottomDividerColor = '#ffffff',
bottomDividerHeight = '50px',
}) => {
const { connectors: { connect, drag } } = useNode();
const hasTopDivider = topDivider && topDivider !== 'none';
const hasBottomDivider = bottomDivider && bottomDivider !== 'none';
return (
<section
ref={(ref: HTMLElement | null) => { if (ref) connect(drag(ref)); }}
style={{
width: '100%',
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
...style,
}}
>
{hasTopDivider && (
<ShapeDivider
shape={topDivider}
color={topDividerColor}
height={topDividerHeight}
position="top"
/>
)}
<Element
id="section-inner"
is={Container}
canvas
style={{ maxWidth: innerMaxWidth, margin: '0 auto', position: 'relative', zIndex: 1 }}
tag="div"
>
{children}
</Element>
{hasBottomDivider && (
<ShapeDivider
shape={bottomDivider}
color={bottomDividerColor}
height={bottomDividerHeight}
position="bottom"
/>
)}
</section>
);
};
/* ---------- Settings panel ---------- */
const sLabelStyle: React.CSSProperties = {
fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6,
textTransform: 'uppercase', letterSpacing: '0.3px',
};
const sInputStyle: React.CSSProperties = {
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const sSelectStyle: React.CSSProperties = {
width: '100%', padding: '5px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const DividerSettings: React.FC<{
label: string;
shape: DividerShape;
color: string;
height: string;
onShapeChange: (s: DividerShape) => void;
onColorChange: (c: string) => void;
onHeightChange: (h: string) => void;
}> = ({ label, shape, color, height, onShapeChange, onColorChange, onHeightChange }) => {
const heightNum = parseInt(height, 10) || 50;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<label style={sLabelStyle}>{label}</label>
{/* Shape selector */}
<select
value={shape || 'none'}
onChange={(e) => onShapeChange(e.target.value as DividerShape)}
style={sSelectStyle}
>
{DIVIDER_SHAPES.map((s) => (
<option key={s} value={s}>{s === 'none' ? 'None' : s.charAt(0).toUpperCase() + s.slice(1)}</option>
))}
</select>
{shape && shape !== 'none' && (
<>
{/* Color picker */}
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input
type="color"
value={color || '#ffffff'}
onChange={(e) => onColorChange(e.target.value)}
style={{ width: 32, height: 28, border: '1px solid #3f3f46', borderRadius: 4, background: 'none', cursor: 'pointer', padding: 0 }}
/>
<input
type="text"
value={color || '#ffffff'}
onChange={(e) => onColorChange(e.target.value)}
style={{ ...sInputStyle, flex: 1 }}
placeholder="#ffffff"
/>
</div>
{/* Height slider */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{ fontSize: 10, color: '#71717a' }}>Height</span>
<span style={{ fontSize: 10, color: '#a1a1aa' }}>{heightNum}px</span>
</div>
<input
type="range"
min={10}
max={200}
value={heightNum}
onChange={(e) => onHeightChange(`${e.target.value}px`)}
style={{ width: '100%', accentColor: '#3b82f6' }}
/>
</div>
{/* Small SVG preview */}
<div style={{ background: '#18181b', borderRadius: 4, padding: 4, border: '1px solid #3f3f46', overflow: 'hidden' }}>
<svg viewBox="0 0 1200 120" preserveAspectRatio="none" style={{ width: '100%', height: 30, fill: color || '#ffffff', display: 'block' }}>
<path d={DIVIDER_PATHS[shape]} />
</svg>
</div>
</>
)}
</div>
);
};
const SectionSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as SectionProps,
}));
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#0f172a', '#1e293b', '#18181b', '#f0fdf4', '#eff6ff'];
const paddingPresets = ['0px', '20px', '40px', '60px', '80px', '120px'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: SectionProps) => { p.style = { ...p.style, backgroundColor: c }; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.style?.backgroundColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Gradient</label>
<input
type="text"
placeholder="e.g. linear-gradient(135deg, #667eea, #764ba2)"
value={(props.style?.background as string) || ''}
onChange={(e) => setProp((p: SectionProps) => { p.style = { ...p.style, background: e.target.value }; })}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Padding (top/bottom)</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{paddingPresets.map((p) => (
<button
key={p}
onClick={() => setProp((pr: SectionProps) => {
pr.style = { ...pr.style, paddingTop: p, paddingBottom: p };
})}
style={{
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.style?.paddingTop === p ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{p}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Inner Max Width</label>
<input
type="text"
value={props.innerMaxWidth || '1200px'}
onChange={(e) => setProp((p: SectionProps) => { p.innerMaxWidth = e.target.value; })}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11 }}
/>
</div>
{/* Divider separator */}
<div style={{ borderTop: '1px solid #3f3f46', paddingTop: 10 }}>
<div style={{ fontSize: 12, fontWeight: 600, color: '#e4e4e7', marginBottom: 10 }}>Shape Dividers</div>
<DividerSettings
label="Top Divider"
shape={props.topDivider || 'none'}
color={props.topDividerColor || '#ffffff'}
height={props.topDividerHeight || '50px'}
onShapeChange={(s) => setProp((p: SectionProps) => { p.topDivider = s; })}
onColorChange={(c) => setProp((p: SectionProps) => { p.topDividerColor = c; })}
onHeightChange={(h) => setProp((p: SectionProps) => { p.topDividerHeight = h; })}
/>
<div style={{ height: 10 }} />
<DividerSettings
label="Bottom Divider"
shape={props.bottomDivider || 'none'}
color={props.bottomDividerColor || '#ffffff'}
height={props.bottomDividerHeight || '50px'}
onShapeChange={(s) => setProp((p: SectionProps) => { p.bottomDivider = s; })}
onColorChange={(c) => setProp((p: SectionProps) => { p.bottomDividerColor = c; })}
onHeightChange={(h) => setProp((p: SectionProps) => { p.bottomDividerHeight = h; })}
/>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Section.craft = {
displayName: 'Section',
props: {
style: { padding: '40px 0', backgroundColor: '#ffffff' },
innerMaxWidth: '1200px',
topDivider: 'none',
topDividerColor: '#ffffff',
topDividerHeight: '50px',
bottomDivider: 'none',
bottomDividerColor: '#ffffff',
bottomDividerHeight: '50px',
},
rules: {
canDrag: () => true,
canMoveIn: () => true,
canMoveOut: () => true,
},
related: {
settings: SectionSettings,
},
};
/* ---------- HTML export ---------- */
function buildDividerHtml(
shape: DividerShape | undefined,
color: string | undefined,
height: string | undefined,
position: 'top' | 'bottom',
): string {
if (!shape || shape === 'none') return '';
const path = DIVIDER_PATHS[shape];
if (!path) return '';
const isTop = position === 'top';
const h = height || '50px';
const c = color || '#ffffff';
const wrapperStyle = cssPropsToString({
position: 'absolute',
[position]: '0',
left: '0',
right: '0',
height: h,
overflow: 'hidden',
lineHeight: '0',
pointerEvents: 'none',
} as CSSProperties);
const svgTransform = isTop ? ' transform:rotate(180deg);' : '';
return `<div style="${wrapperStyle}"><svg viewBox="0 0 1200 120" preserveAspectRatio="none" style="width:100%;height:100%;fill:${c};display:block;${svgTransform}"><path d="${path}"/></svg></div>`;
}
(Section as any).toHtml = (props: SectionProps, childrenHtml: string) => {
const hasTopDivider = props.topDivider && props.topDivider !== 'none';
const hasBottomDivider = props.bottomDivider && props.bottomDivider !== 'none';
const outerStyle = cssPropsToString({
width: '100%',
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
...props.style,
});
const innerStyle = cssPropsToString({
maxWidth: props.innerMaxWidth || '1200px',
margin: '0 auto',
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
zIndex: (hasTopDivider || hasBottomDivider) ? 1 : undefined,
} as CSSProperties);
const topHtml = buildDividerHtml(props.topDivider, props.topDividerColor, props.topDividerHeight, 'top');
const bottomHtml = buildDividerHtml(props.bottomDivider, props.bottomDividerColor, props.bottomDividerHeight, 'bottom');
return {
html: `<section${outerStyle ? ` style="${outerStyle}"` : ''}>${topHtml}<div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div>${bottomHtml}</section>`,
};
};