Real-world AI output frequently sends mismatched prop names (e.g. items vs features, cta object vs buttonText/Href). The toHtml functions of section/form/sections-folder components each defined a local esc = (s: string) => s.replace(...) that crashed when called with undefined, taking the auto-save export with it. Patched every local esc() to coerce non-strings: const esc = (s: any) => String(s ?? "").replace(...) 17 files touched; behavior unchanged for valid string inputs. Also adds a WorkingIndicator (Claude Code-style spinner + rotating phrase + elapsed seconds) shown in the modal footer while a generation is in flight, replacing the disabled "Thinking..." placeholder.
458 lines
18 KiB
TypeScript
458 lines
18 KiB
TypeScript
import React, { CSSProperties } from 'react';
|
|
import { useNode, UserComponent } from '@craftjs/core';
|
|
import { cssPropsToString } from '../../utils/style-helpers';
|
|
|
|
interface HeroProps {
|
|
heading?: string;
|
|
subtitle?: string;
|
|
buttonText?: string;
|
|
buttonHref?: string;
|
|
secondaryButtonText?: string;
|
|
secondaryButtonHref?: string;
|
|
bgType?: 'color' | 'gradient' | 'image' | 'video';
|
|
bgColor?: string;
|
|
bgGradientFrom?: string;
|
|
bgGradientTo?: string;
|
|
bgGradientAngle?: number;
|
|
bgImage?: string;
|
|
bgVideo?: string;
|
|
overlayColor?: string;
|
|
overlayOpacity?: number;
|
|
textColor?: string;
|
|
buttonBgColor?: string;
|
|
buttonTextColor?: string;
|
|
minHeight?: string;
|
|
verticalAlign?: 'top' | 'center' | 'bottom';
|
|
textAlign?: 'left' | 'center' | 'right';
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
// Helper: build the background CSS value
|
|
function buildBackground(props: HeroProps): string {
|
|
switch (props.bgType) {
|
|
case 'gradient':
|
|
return `linear-gradient(${props.bgGradientAngle || 135}deg, ${props.bgGradientFrom || '#667eea'}, ${props.bgGradientTo || '#764ba2'})`;
|
|
case 'image':
|
|
return props.bgImage ? `url('${props.bgImage}') center/cover no-repeat` : '#1e293b';
|
|
case 'color':
|
|
default:
|
|
return props.bgColor || '#1e293b';
|
|
}
|
|
}
|
|
|
|
export const HeroSimple: UserComponent<HeroProps> = ({
|
|
heading = 'Build Something Amazing',
|
|
subtitle = 'Create beautiful websites without writing a single line of code.',
|
|
buttonText = 'Get Started',
|
|
buttonHref = '#',
|
|
secondaryButtonText = '',
|
|
secondaryButtonHref = '#',
|
|
bgType = 'color',
|
|
bgColor = '#1e293b',
|
|
bgGradientFrom = '#667eea',
|
|
bgGradientTo = '#764ba2',
|
|
bgGradientAngle = 135,
|
|
bgImage = '',
|
|
bgVideo = '',
|
|
overlayColor = '#000000',
|
|
overlayOpacity = 0,
|
|
textColor = '#ffffff',
|
|
buttonBgColor = '#3b82f6',
|
|
buttonTextColor = '#ffffff',
|
|
minHeight = '500px',
|
|
verticalAlign = 'center',
|
|
textAlign = 'center',
|
|
style = {},
|
|
}) => {
|
|
const { connectors: { connect, drag } } = useNode();
|
|
|
|
const bg = buildBackground({
|
|
bgType, bgColor, bgGradientFrom, bgGradientTo, bgGradientAngle, bgImage,
|
|
} as HeroProps);
|
|
|
|
const justifyMap = { top: 'flex-start', center: 'center', bottom: 'flex-end' };
|
|
|
|
return (
|
|
<section
|
|
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
|
style={{
|
|
...style,
|
|
background: bgType !== 'image' ? bg : undefined,
|
|
backgroundImage: bgType === 'image' && bgImage ? `url('${bgImage}')` : undefined,
|
|
backgroundSize: bgType === 'image' ? 'cover' : undefined,
|
|
backgroundPosition: bgType === 'image' ? 'center' : undefined,
|
|
minHeight: minHeight === '100vh' ? '100vh' : minHeight,
|
|
display: 'flex',
|
|
alignItems: justifyMap[verticalAlign] || 'center',
|
|
justifyContent: 'center',
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
padding: '60px 20px',
|
|
}}
|
|
>
|
|
{/* Video background */}
|
|
{bgType === 'video' && bgVideo && (
|
|
<video
|
|
src={bgVideo}
|
|
autoPlay muted loop playsInline
|
|
style={{
|
|
position: 'absolute', top: 0, left: 0, width: '100%', height: '100%',
|
|
objectFit: 'cover', zIndex: 0,
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Overlay (renders AFTER video so it sits on top) */}
|
|
{overlayOpacity > 0 && (
|
|
<div style={{
|
|
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
|
|
backgroundColor: overlayColor,
|
|
opacity: overlayOpacity / 100,
|
|
zIndex: 1,
|
|
}} />
|
|
)}
|
|
|
|
{/* Content */}
|
|
<div style={{
|
|
maxWidth: '800px',
|
|
width: '100%',
|
|
position: 'relative',
|
|
zIndex: 2,
|
|
textAlign: textAlign as any,
|
|
}}>
|
|
<h1 style={{
|
|
fontSize: '48px', fontWeight: '700', color: textColor,
|
|
marginBottom: '16px', lineHeight: '1.2',
|
|
}}>
|
|
{heading}
|
|
</h1>
|
|
<p style={{
|
|
fontSize: '20px', color: textColor,
|
|
opacity: 0.85, marginBottom: '32px', lineHeight: '1.6',
|
|
whiteSpace: 'pre-line',
|
|
}}>
|
|
{subtitle}
|
|
</p>
|
|
<div style={{ display: 'flex', gap: '12px', justifyContent: textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start', flexWrap: 'wrap' }}>
|
|
{buttonText && (
|
|
<a href={buttonHref} onClick={(e) => e.preventDefault()} style={{
|
|
display: 'inline-block', padding: '14px 36px', backgroundColor: buttonBgColor,
|
|
color: buttonTextColor, textDecoration: 'none', borderRadius: '8px',
|
|
fontWeight: '600', fontSize: '16px',
|
|
}}>
|
|
{buttonText}
|
|
</a>
|
|
)}
|
|
{secondaryButtonText && (
|
|
<a href={secondaryButtonHref} onClick={(e) => e.preventDefault()} style={{
|
|
display: 'inline-block', padding: '14px 36px',
|
|
backgroundColor: 'transparent', color: textColor,
|
|
textDecoration: 'none', borderRadius: '8px', fontWeight: '600',
|
|
fontSize: '16px', border: `2px solid ${textColor}`,
|
|
}}>
|
|
{secondaryButtonText}
|
|
</a>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
/* ---------- Settings panel ---------- */
|
|
|
|
const inputStyle: React.CSSProperties = {
|
|
width: '100%', padding: '6px 8px', background: '#27272a',
|
|
color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
|
|
};
|
|
const labelStyle: React.CSSProperties = {
|
|
fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4,
|
|
};
|
|
const btnStyle = (active: boolean): React.CSSProperties => ({
|
|
flex: 1, padding: '6px 4px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
|
|
border: '1px solid #3f3f46',
|
|
background: active ? '#3b82f6' : '#27272a',
|
|
color: active ? '#fff' : '#a1a1aa',
|
|
fontWeight: active ? 600 : 400,
|
|
});
|
|
|
|
const HeroSettings: React.FC = () => {
|
|
const { actions: { setProp }, props } = useNode((node) => ({
|
|
props: node.data.props as HeroProps,
|
|
}));
|
|
|
|
return (
|
|
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
{/* Content */}
|
|
<div>
|
|
<label style={labelStyle}>Heading</label>
|
|
<input type="text" value={props.heading || ''} onChange={(e) => setProp((p: HeroProps) => { p.heading = e.target.value; })} style={inputStyle} />
|
|
</div>
|
|
<div>
|
|
<label style={labelStyle}>Subtitle</label>
|
|
<textarea value={props.subtitle || ''} onChange={(e) => setProp((p: HeroProps) => { p.subtitle = e.target.value; })} rows={3} style={{ ...inputStyle, resize: 'vertical' as const }} />
|
|
</div>
|
|
<div>
|
|
<label style={labelStyle}>Button Text</label>
|
|
<input type="text" value={props.buttonText || ''} onChange={(e) => setProp((p: HeroProps) => { p.buttonText = e.target.value; })} style={inputStyle} />
|
|
</div>
|
|
<div>
|
|
<label style={labelStyle}>Button URL</label>
|
|
<input type="text" value={props.buttonHref || ''} onChange={(e) => setProp((p: HeroProps) => { p.buttonHref = e.target.value; })} placeholder="#" style={inputStyle} />
|
|
</div>
|
|
<div>
|
|
<label style={labelStyle}>Secondary Button Text</label>
|
|
<input type="text" value={props.secondaryButtonText || ''} onChange={(e) => setProp((p: HeroProps) => { p.secondaryButtonText = e.target.value; })} placeholder="Leave blank to hide" style={inputStyle} />
|
|
</div>
|
|
|
|
{/* Background Type */}
|
|
<div>
|
|
<label style={labelStyle}>Background Type</label>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
{(['color', 'gradient', 'image', 'video'] as const).map((t) => (
|
|
<button key={t} onClick={() => setProp((p: HeroProps) => { p.bgType = t; })}
|
|
style={btnStyle(props.bgType === t)}>
|
|
{t === 'color' ? 'Color' : t === 'gradient' ? 'Gradient' : t === 'image' ? 'Image' : 'Video'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Background controls based on type */}
|
|
{props.bgType === 'color' && (
|
|
<div>
|
|
<label style={labelStyle}>Background Color</label>
|
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
|
<input type="color" value={props.bgColor || '#1e293b'}
|
|
onChange={(e) => setProp((p: HeroProps) => { p.bgColor = e.target.value; })}
|
|
style={{ width: 36, height: 30, border: 'none', cursor: 'pointer', background: 'none' }} />
|
|
<input type="text" value={props.bgColor || '#1e293b'}
|
|
onChange={(e) => setProp((p: HeroProps) => { p.bgColor = e.target.value; })}
|
|
style={{ ...inputStyle, flex: 1 }} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{props.bgType === 'gradient' && (
|
|
<>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<div style={{ flex: 1 }}>
|
|
<label style={labelStyle}>From</label>
|
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
|
<input type="color" value={props.bgGradientFrom || '#667eea'}
|
|
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientFrom = e.target.value; })}
|
|
style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} />
|
|
<input type="text" value={props.bgGradientFrom || '#667eea'}
|
|
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientFrom = e.target.value; })}
|
|
style={{ ...inputStyle, fontSize: 10 }} />
|
|
</div>
|
|
</div>
|
|
<div style={{ flex: 1 }}>
|
|
<label style={labelStyle}>To</label>
|
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
|
<input type="color" value={props.bgGradientTo || '#764ba2'}
|
|
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientTo = e.target.value; })}
|
|
style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} />
|
|
<input type="text" value={props.bgGradientTo || '#764ba2'}
|
|
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientTo = e.target.value; })}
|
|
style={{ ...inputStyle, fontSize: 10 }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label style={labelStyle}>Angle: {props.bgGradientAngle || 135}°</label>
|
|
<input type="range" min={0} max={360} value={props.bgGradientAngle || 135}
|
|
onChange={(e) => setProp((p: HeroProps) => { p.bgGradientAngle = parseInt(e.target.value); })}
|
|
style={{ width: '100%' }} />
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{props.bgType === 'image' && (
|
|
<div>
|
|
<label style={labelStyle}>Background Image URL</label>
|
|
<input type="text" value={props.bgImage || ''} placeholder="https://..."
|
|
onChange={(e) => setProp((p: HeroProps) => { p.bgImage = e.target.value; })} style={inputStyle} />
|
|
</div>
|
|
)}
|
|
|
|
{props.bgType === 'video' && (
|
|
<div>
|
|
<label style={labelStyle}>Background Video URL</label>
|
|
<input type="text" value={props.bgVideo || ''} placeholder="https://...mp4"
|
|
onChange={(e) => setProp((p: HeroProps) => { p.bgVideo = e.target.value; })} style={inputStyle} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Overlay */}
|
|
{(props.bgType === 'image' || props.bgType === 'video') && (
|
|
<div>
|
|
<label style={labelStyle}>Overlay ({props.overlayOpacity || 0}%)</label>
|
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
|
<input type="color" value={props.overlayColor || '#000000'}
|
|
onChange={(e) => setProp((p: HeroProps) => { p.overlayColor = e.target.value; })}
|
|
style={{ width: 30, height: 26, border: 'none', cursor: 'pointer', background: 'none' }} />
|
|
<input type="range" min={0} max={100} value={props.overlayOpacity || 0}
|
|
onChange={(e) => setProp((p: HeroProps) => { p.overlayOpacity = parseInt(e.target.value); })}
|
|
style={{ flex: 1 }} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Text & Button Colors */}
|
|
<div>
|
|
<label style={labelStyle}>Text Color</label>
|
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
|
<input type="color" value={props.textColor || '#ffffff'}
|
|
onChange={(e) => setProp((p: HeroProps) => { p.textColor = e.target.value; })}
|
|
style={{ width: 36, height: 30, border: 'none', cursor: 'pointer', background: 'none' }} />
|
|
<input type="text" value={props.textColor || '#ffffff'}
|
|
onChange={(e) => setProp((p: HeroProps) => { p.textColor = e.target.value; })}
|
|
style={{ ...inputStyle, flex: 1 }} />
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<div style={{ flex: 1 }}>
|
|
<label style={labelStyle}>Button BG</label>
|
|
<input type="color" value={props.buttonBgColor || '#3b82f6'}
|
|
onChange={(e) => setProp((p: HeroProps) => { p.buttonBgColor = e.target.value; })}
|
|
style={{ width: '100%', height: 30, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }} />
|
|
</div>
|
|
<div style={{ flex: 1 }}>
|
|
<label style={labelStyle}>Button Text</label>
|
|
<input type="color" value={props.buttonTextColor || '#ffffff'}
|
|
onChange={(e) => setProp((p: HeroProps) => { p.buttonTextColor = e.target.value; })}
|
|
style={{ width: '100%', height: 30, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Layout */}
|
|
<div>
|
|
<label style={labelStyle}>Min Height</label>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
{['300px', '400px', '500px', '600px', '100vh'].map((h) => (
|
|
<button key={h} onClick={() => setProp((p: HeroProps) => { p.minHeight = h; })}
|
|
style={btnStyle(props.minHeight === h)}>{h === '100vh' ? 'Full' : h}</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label style={labelStyle}>Vertical Align</label>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
{(['top', 'center', 'bottom'] as const).map((v) => (
|
|
<button key={v} onClick={() => setProp((p: HeroProps) => { p.verticalAlign = v; })}
|
|
style={btnStyle(props.verticalAlign === v)}>{v}</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label style={labelStyle}>Text Align</label>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
{(['left', 'center', 'right'] as const).map((a) => (
|
|
<button key={a} onClick={() => setProp((p: HeroProps) => { p.textAlign = a; })}
|
|
style={btnStyle(props.textAlign === a)}>
|
|
<i className={`fa fa-align-${a}`} />
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/* ---------- Craft config ---------- */
|
|
|
|
HeroSimple.craft = {
|
|
displayName: 'Hero',
|
|
props: {
|
|
heading: 'Build Something Amazing',
|
|
subtitle: 'Create beautiful websites without writing a single line of code.',
|
|
buttonText: 'Get Started',
|
|
buttonHref: '#',
|
|
secondaryButtonText: '',
|
|
secondaryButtonHref: '#',
|
|
bgType: 'color',
|
|
bgColor: '#1e293b',
|
|
bgGradientFrom: '#667eea',
|
|
bgGradientTo: '#764ba2',
|
|
bgGradientAngle: 135,
|
|
bgImage: '',
|
|
bgVideo: '',
|
|
overlayColor: '#000000',
|
|
overlayOpacity: 0,
|
|
textColor: '#ffffff',
|
|
buttonBgColor: '#3b82f6',
|
|
buttonTextColor: '#ffffff',
|
|
minHeight: '500px',
|
|
verticalAlign: 'center',
|
|
textAlign: 'center',
|
|
style: {},
|
|
},
|
|
rules: {
|
|
canDrag: () => true,
|
|
canMoveIn: () => false,
|
|
canMoveOut: () => true,
|
|
},
|
|
related: {
|
|
settings: HeroSettings,
|
|
},
|
|
};
|
|
|
|
/* ---------- HTML export ---------- */
|
|
|
|
(HeroSimple as any).toHtml = (props: HeroProps, _childrenHtml: string) => {
|
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>');
|
|
const bg = buildBackground(props);
|
|
const justifyMap: Record<string, string> = { top: 'flex-start', center: 'center', bottom: 'flex-end' };
|
|
|
|
const sectionStyle = cssPropsToString({
|
|
background: props.bgType !== 'image' ? bg : undefined,
|
|
backgroundImage: props.bgType === 'image' && props.bgImage ? `url('${props.bgImage}')` : undefined,
|
|
backgroundSize: props.bgType === 'image' ? 'cover' : undefined,
|
|
backgroundPosition: props.bgType === 'image' ? 'center' : undefined,
|
|
minHeight: props.minHeight || '500px',
|
|
display: 'flex',
|
|
alignItems: justifyMap[props.verticalAlign || 'center'],
|
|
justifyContent: 'center',
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
padding: '60px 20px',
|
|
...props.style,
|
|
});
|
|
|
|
let overlayHtml = '';
|
|
if ((props.overlayOpacity || 0) > 0) {
|
|
overlayHtml = `<div style="position:absolute;top:0;left:0;right:0;bottom:0;background-color:${props.overlayColor || '#000'};opacity:${(props.overlayOpacity || 0) / 100};z-index:1"></div>`;
|
|
}
|
|
|
|
let videoHtml = '';
|
|
if (props.bgType === 'video' && props.bgVideo) {
|
|
videoHtml = `<video src="${props.bgVideo}" autoplay muted loop playsinline style="position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;z-index:0"></video>`;
|
|
}
|
|
|
|
const textAlign = props.textAlign || 'center';
|
|
const justifyBtn = textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start';
|
|
|
|
let buttonsHtml = '';
|
|
if (props.buttonText) {
|
|
buttonsHtml += `<a href="${props.buttonHref || '#'}" style="display:inline-block;padding:14px 36px;background-color:${props.buttonBgColor || '#3b82f6'};color:${props.buttonTextColor || '#fff'};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px">${esc(props.buttonText)}</a>`;
|
|
}
|
|
if (props.secondaryButtonText) {
|
|
buttonsHtml += `<a href="${props.secondaryButtonHref || '#'}" style="display:inline-block;padding:14px 36px;background:transparent;color:${props.textColor || '#fff'};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px;border:2px solid ${props.textColor || '#fff'}">${esc(props.secondaryButtonText)}</a>`;
|
|
}
|
|
|
|
return {
|
|
html: `<section style="${sectionStyle}">
|
|
${videoHtml}${overlayHtml}
|
|
<div style="max-width:800px;width:100%;position:relative;z-index:2;text-align:${textAlign}">
|
|
<h1 style="font-size:48px;font-weight:700;color:${props.textColor || '#fff'};margin-bottom:16px;line-height:1.2">${esc(props.heading || '')}</h1>
|
|
<p style="font-size:20px;color:${props.textColor || '#fff'};opacity:0.85;margin-bottom:32px;line-height:1.6;white-space:pre-line">${esc(props.subtitle || '')}</p>
|
|
<div style="display:flex;gap:12px;justify-content:${justifyBtn};flex-wrap:wrap">${buttonsHtml}</div>
|
|
</div>
|
|
</section>`,
|
|
};
|
|
};
|