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

View File

@@ -0,0 +1,232 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface ButtonLinkProps {
text?: string;
href?: string;
target?: '_self' | '_blank';
style?: CSSProperties;
}
export const ButtonLink: UserComponent<ButtonLinkProps> = ({
text = 'Click Me',
href = '#',
target = '_self',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<a
ref={(ref: HTMLAnchorElement | null) => { if (ref) connect(drag(ref)); }}
href={href}
target={target}
onClick={(e) => {
// Prevent navigation inside editor
e.preventDefault();
}}
style={{
display: 'inline-block',
textDecoration: 'none',
cursor: 'pointer',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
{text}
</a>
);
};
/* ---------- Settings panel ---------- */
const ButtonLinkSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as ButtonLinkProps,
}));
const colorPresets = [
{ bg: '#3b82f6', color: '#ffffff', label: 'Blue' },
{ bg: '#10b981', color: '#ffffff', label: 'Green' },
{ bg: '#ef4444', color: '#ffffff', label: 'Red' },
{ bg: '#f59e0b', color: '#18181b', label: 'Amber' },
{ bg: '#8b5cf6', color: '#ffffff', label: 'Purple' },
{ bg: '#18181b', color: '#ffffff', label: 'Dark' },
{ bg: '#ffffff', color: '#18181b', label: 'White' },
{ bg: 'transparent', color: '#3b82f6', label: 'Ghost' },
];
const radiusPresets = ['0px', '4px', '8px', '12px', '9999px'];
const paddingPresets = ['8px 16px', '10px 20px', '12px 24px', '14px 32px', '16px 40px'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Text</label>
<input
type="text"
value={props.text || ''}
onChange={(e) => setProp((p: ButtonLinkProps) => { p.text = e.target.value; })}
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 }}>Link URL</label>
<input
type="text"
value={props.href || ''}
onChange={(e) => setProp((p: ButtonLinkProps) => { p.href = e.target.value; })}
placeholder="https://..."
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 }}>Target</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['_self', '_blank'] as const).map((t) => (
<button
key={t}
onClick={() => setProp((p: ButtonLinkProps) => { p.target = t; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.target === t ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{t === '_self' ? 'Same Tab' : 'New Tab'}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorPresets.map((preset) => (
<button
key={preset.label}
onClick={() => setProp((p: ButtonLinkProps) => {
p.style = {
...p.style,
backgroundColor: preset.bg,
color: preset.color,
border: preset.bg === 'transparent' ? `1px solid ${preset.color}` : 'none',
};
})}
title={preset.label}
style={{
width: 24, height: 24, borderRadius: 4,
border: preset.bg === 'transparent' ? `2px solid ${preset.color}` : '1px solid #3f3f46',
backgroundColor: preset.bg, cursor: 'pointer',
outline: props.style?.backgroundColor === preset.bg ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Border Radius</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{radiusPresets.map((r) => (
<button
key={r}
onClick={() => setProp((p: ButtonLinkProps) => { p.style = { ...p.style, borderRadius: r }; })}
style={{
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.style?.borderRadius === r ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{r}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Padding</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{paddingPresets.map((p) => (
<button
key={p}
onClick={() => setProp((pr: ButtonLinkProps) => { pr.style = { ...pr.style, padding: p }; })}
style={{
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.style?.padding === p ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{p}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Font Size</label>
<input
type="text"
placeholder="e.g. 16px"
value={(props.style?.fontSize as string) || ''}
onChange={(e) => setProp((p: ButtonLinkProps) => { p.style = { ...p.style, fontSize: 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 ---------- */
ButtonLink.craft = {
displayName: 'Button',
props: {
text: 'Click Me',
href: '#',
target: '_self',
style: {
backgroundColor: '#3b82f6',
color: '#ffffff',
padding: '12px 24px',
borderRadius: '8px',
fontWeight: '600',
fontSize: '16px',
border: 'none',
},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: ButtonLinkSettings,
},
};
/* ---------- HTML export ---------- */
(ButtonLink as any).toHtml = (props: ButtonLinkProps, _childrenHtml: string) => {
const styleStr = cssPropsToString({
display: 'inline-block',
textDecoration: 'none',
...props.style,
});
const escapedText = (props.text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const targetAttr = props.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
return {
html: `<a href="${props.href || '#'}"${targetAttr}${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</a>`,
};
};

View File

@@ -0,0 +1,119 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface DividerProps {
color?: string;
thickness?: string;
style?: CSSProperties;
}
export const Divider: UserComponent<DividerProps> = ({
color = '#e4e4e7',
thickness = '1px',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<hr
ref={(ref: HTMLHRElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
border: 'none',
borderTop: `${thickness} solid ${color}`,
margin: '16px 0',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
/>
);
};
/* ---------- Settings panel ---------- */
const DividerSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as DividerProps,
}));
const colorPresets = ['#e4e4e7', '#d4d4d8', '#a1a1aa', '#3f3f46', '#18181b', '#3b82f6', '#ef4444', '#10b981'];
const thicknessPresets = ['1px', '2px', '3px', '4px', '6px'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: DividerProps) => { p.color = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.color === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Thickness</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{thicknessPresets.map((t) => (
<button
key={t}
onClick={() => setProp((p: DividerProps) => { p.thickness = t; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.thickness === t ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{t}
</button>
))}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Divider.craft = {
displayName: 'Divider',
props: {
color: '#e4e4e7',
thickness: '1px',
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: DividerSettings,
},
};
/* ---------- HTML export ---------- */
(Divider as any).toHtml = (props: DividerProps, _childrenHtml: string) => {
const styleStr = cssPropsToString({
border: 'none',
borderTop: `${props.thickness || '1px'} solid ${props.color || '#e4e4e7'}`,
margin: '16px 0',
...props.style,
});
return { html: `<hr${styleStr ? ` style="${styleStr}"` : ''} />` };
};

View File

@@ -0,0 +1,153 @@
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface FooterProps {
text?: string;
style?: CSSProperties;
}
export const Footer: UserComponent<FooterProps> = ({
text = '© 2026 MySite. All rights reserved.',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
actions: { setProp },
} = useNode((node) => ({
selected: node.events.selected,
}));
const elRef = useRef<HTMLElement | null>(null);
const handleBlur = useCallback(() => {
if (elRef.current) {
const newText = elRef.current.innerText;
setProp((p: FooterProps) => { p.text = newText; }, 500);
}
}, [setProp]);
useEffect(() => {
if (elRef.current && !selected) {
elRef.current.innerText = text || '';
}
}, [text, selected]);
return (
<footer
ref={(ref: HTMLElement | null): void => {
elRef.current = ref;
if (ref) connect(drag(ref));
}}
contentEditable={selected}
suppressContentEditableWarning
onBlur={handleBlur}
style={{
padding: '24px 20px',
textAlign: 'center',
outline: 'none',
cursor: selected ? 'text' : 'pointer',
...style,
}}
>
{selected ? undefined : (text || '')}
</footer>
);
};
/* ---------- Settings panel ---------- */
const FooterSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as FooterProps,
}));
const bgPresets = ['#ffffff', '#f8fafc', '#18181b', '#0f172a', '#1e293b'];
const colorPresets = ['#18181b', '#3f3f46', '#71717a', '#a1a1aa', '#e4e4e7', '#ffffff'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Footer Text</label>
<input
type="text"
value={props.text || ''}
onChange={(e) => setProp((p: FooterProps) => { p.text = e.target.value; })}
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</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: FooterProps) => { 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 }}>Text Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: FooterProps) => { p.style = { ...p.style, color: c }; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.style?.color === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Footer.craft = {
displayName: 'Footer',
props: {
text: '© 2026 MySite. All rights reserved.',
style: {
backgroundColor: '#18181b',
color: '#a1a1aa',
fontSize: '14px',
padding: '24px 20px',
},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: FooterSettings,
},
};
/* ---------- HTML export ---------- */
(Footer as any).toHtml = (props: FooterProps, _childrenHtml: string) => {
const styleStr = cssPropsToString({
padding: '24px 20px',
textAlign: 'center',
...props.style,
});
const escapedText = (props.text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return { html: `<footer${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</footer>` };
};

View File

@@ -0,0 +1,181 @@
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { SettingsTabs } from '../../ui/SettingsTabs';
import { TypographyControl } from '../../ui/TypographyControl';
import { AdvancedTab } from '../../ui/AdvancedTab';
type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
interface HeadingProps {
text?: string;
level?: HeadingLevel;
style?: CSSProperties;
cssId?: string;
cssClass?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
}
export const Heading: UserComponent<HeadingProps> = ({
text = 'Heading',
level = 'h2',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
actions: { setProp },
} = useNode((node) => ({
selected: node.events.selected,
}));
const elRef = useRef<HTMLElement | null>(null);
const editedTextRef = useRef<string | null>(null);
const commitText = useCallback(() => {
if (elRef.current) {
const newText = elRef.current.innerText;
editedTextRef.current = newText;
setProp((p: HeadingProps) => { p.text = newText; });
}
}, [setProp]);
// Commit on blur
const handleBlur = useCallback(() => { commitText(); }, [commitText]);
// Also commit on deselect via effect
useEffect(() => {
if (!selected && editedTextRef.current !== null) {
setProp((p: HeadingProps) => { p.text = editedTextRef.current!; });
editedTextRef.current = null;
}
}, [selected, setProp]);
// Set DOM text on mount and when text prop changes externally (not during editing)
useEffect(() => {
if (elRef.current && !selected && editedTextRef.current === null) {
elRef.current.innerText = text || '';
}
}, [text, selected]);
return React.createElement(level, {
ref: (ref: HTMLElement | null): void => {
elRef.current = ref;
if (ref) connect(drag(ref));
},
contentEditable: selected,
suppressContentEditableWarning: true,
onBlur: handleBlur,
onInput: () => {
// Track that we have unsaved edits
if (elRef.current) {
editedTextRef.current = elRef.current.innerText;
}
},
style: { outline: 'none', cursor: selected ? 'text' : 'pointer', minHeight: '1em', ...style },
});
};
/* ---------- Settings panel ---------- */
const HeadingSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as HeadingProps,
}));
const levels: HeadingLevel[] = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'];
return (
<SettingsTabs
general={
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={{ fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.3px' }}>Heading Level</label>
<div style={{ display: 'flex', gap: 4 }}>
{levels.map((l) => (
<button
key={l}
onClick={() => setProp((p: HeadingProps) => { p.level = l; })}
style={{
flex: 1, padding: '4px 0', borderRadius: 4, border: '1px solid #3f3f46', cursor: 'pointer',
background: props.level === l ? '#3b82f6' : '#27272a', color: props.level === l ? '#fff' : '#a1a1aa',
fontSize: 12, fontWeight: 600,
}}
>{l.toUpperCase()}</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.3px' }}>Text</label>
<input
type="text"
value={props.text || ''}
onChange={(e) => setProp((p: HeadingProps) => { p.text = e.target.value; })}
style={{ width: '100%', padding: '6px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 13 }}
/>
</div>
</div>
}
style={
<TypographyControl
style={props.style || {}}
onChange={(updates) => setProp((p: HeadingProps) => { p.style = { ...p.style, ...updates }; })}
/>
}
advanced={
<AdvancedTab
style={props.style || {}}
onStyleChange={(updates) => setProp((p: HeadingProps) => { p.style = { ...p.style, ...updates }; })}
cssId={props.cssId || ''}
onCssIdChange={(id) => setProp((p: HeadingProps) => { p.cssId = id; })}
cssClass={props.cssClass || ''}
onCssClassChange={(cls) => setProp((p: HeadingProps) => { p.cssClass = cls; })}
hideOnDesktop={props.hideOnDesktop}
onHideOnDesktopChange={(v) => setProp((p: HeadingProps) => { p.hideOnDesktop = v; })}
hideOnTablet={props.hideOnTablet}
onHideOnTabletChange={(v) => setProp((p: HeadingProps) => { p.hideOnTablet = v; })}
hideOnMobile={props.hideOnMobile}
onHideOnMobileChange={(v) => setProp((p: HeadingProps) => { p.hideOnMobile = v; })}
animation={props.animation}
onAnimationChange={(v) => setProp((p: HeadingProps) => { p.animation = v; })}
animationDelay={props.animationDelay}
onAnimationDelayChange={(v) => setProp((p: HeadingProps) => { p.animationDelay = v; })}
/>
}
/>
);
};
Heading.craft = {
displayName: 'Heading',
props: {
text: 'Your Heading',
level: 'h2' as HeadingLevel,
style: {
fontSize: '36px',
fontWeight: '700',
fontFamily: 'Inter, sans-serif',
color: '#1f2937',
marginBottom: '16px',
},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: HeadingSettings,
},
};
(Heading as any).toHtml = (props: HeadingProps, _childrenHtml: string) => {
const tag = props.level || 'h2';
const safeText = (props.text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const styleStr = cssPropsToString(props.style);
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${safeText}</${tag}>` };
};

View File

@@ -0,0 +1,127 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface HtmlBlockProps {
code: string;
style?: CSSProperties;
}
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({
code = '',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<div
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
minHeight: '40px',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
dangerouslySetInnerHTML={{ __html: code }}
/>
);
};
/* ---------- Settings panel ---------- */
const HtmlBlockSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as HtmlBlockProps,
}));
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div style={{
padding: '8px 10px',
background: '#44200a',
border: '1px solid #92400e',
borderRadius: 6,
fontSize: 11,
color: '#fbbf24',
lineHeight: 1.4,
}}>
This block renders raw HTML. Use with caution.
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>HTML Code</label>
<textarea
value={props.code || ''}
onChange={(e) => setProp((p: HtmlBlockProps) => { p.code = e.target.value; })}
placeholder="<div>Your HTML here...</div>"
rows={16}
style={{
width: '100%',
padding: '10px',
background: '#1a1a2e',
color: '#a5f3fc',
border: '1px solid #3f3f46',
borderRadius: 6,
fontSize: 12,
fontFamily: '"Source Code Pro", "Fira Code", monospace',
lineHeight: 1.5,
resize: 'vertical',
boxSizing: 'border-box',
whiteSpace: 'pre',
tabSize: 2,
}}
/>
</div>
{/* Outer container style */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Padding</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['0px', '8px', '16px', '24px', '32px'].map((p) => (
<button
key={p}
onClick={() => setProp((pr: HtmlBlockProps) => { pr.style = { ...pr.style, padding: p }; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.style?.padding === p ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{p}
</button>
))}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
HtmlBlock.craft = {
displayName: 'HTML',
props: {
code: '',
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: HtmlBlockSettings,
},
};
/* ---------- HTML export ---------- */
(HtmlBlock as any).toHtml = (props: HtmlBlockProps, _childrenHtml: string) => {
// Output the raw code as-is
return { html: props.code || '' };
};

View File

@@ -0,0 +1,325 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface IconProps {
icon?: string;
size?: string;
color?: string;
bgColor?: string;
bgShape?: 'none' | 'circle' | 'square' | 'rounded';
bgSize?: string;
link?: string;
style?: CSSProperties;
}
const COMMON_ICONS = [
'fa-star', 'fa-heart', 'fa-check', 'fa-phone', 'fa-envelope',
'fa-map-marker', 'fa-globe', 'fa-facebook', 'fa-twitter', 'fa-instagram',
'fa-linkedin', 'fa-youtube', 'fa-github', 'fa-arrow-right', 'fa-arrow-down',
'fa-play', 'fa-search', 'fa-user', 'fa-lock', 'fa-cog',
'fa-home', 'fa-comment', 'fa-camera', 'fa-music', 'fa-shopping-cart',
'fa-calendar', 'fa-clock-o', 'fa-thumbs-up', 'fa-lightbulb-o', 'fa-rocket',
];
function getBgBorderRadius(shape: string): string {
if (shape === 'circle') return '50%';
if (shape === 'rounded') return '8px';
if (shape === 'square') return '0px';
return '0px';
}
export const Icon: UserComponent<IconProps> = ({
icon = 'fa-star',
size = '32px',
color = '#3b82f6',
bgColor = 'transparent',
bgShape = 'none',
bgSize = '56px',
link = '',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const iconEl = (
<i
className={`fa ${icon}`}
style={{ fontSize: size, color, lineHeight: 1 }}
/>
);
const hasBg = bgShape !== 'none' && bgColor !== 'transparent';
const wrapperEl = hasBg ? (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: bgSize,
height: bgSize,
backgroundColor: bgColor,
borderRadius: getBgBorderRadius(bgShape || 'none'),
}}
>
{iconEl}
</div>
) : iconEl;
const content = link ? (
<a href={link} onClick={(e) => e.preventDefault()} style={{ textDecoration: 'none', color: 'inherit' }}>
{wrapperEl}
</a>
) : wrapperEl;
return (
<div
ref={(ref: HTMLDivElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
display: 'inline-block',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
{content}
</div>
);
};
/* ---------- Settings panel ---------- */
const IconSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as IconProps,
}));
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
const inputStyle: CSSProperties = {
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
};
const sizePresets = ['24px', '32px', '48px', '64px'];
const colorPresets = ['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', '#ec4899', '#18181b', '#ffffff'];
const shapePresets: Array<{ label: string; value: IconProps['bgShape'] }> = [
{ label: 'None', value: 'none' },
{ label: 'Circle', value: 'circle' },
{ label: 'Square', value: 'square' },
{ label: 'Rounded', value: 'rounded' },
];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Icon picker */}
<div>
<label style={labelStyle}>Icon</label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: 4, maxHeight: 200, overflowY: 'auto' }}>
{COMMON_ICONS.map((ic) => (
<button
key={ic}
onClick={() => setProp((p: IconProps) => { p.icon = ic; })}
title={ic}
style={{
padding: '6px', fontSize: 16, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.icon === ic ? '#3b82f6' : '#27272a',
color: props.icon === ic ? '#fff' : '#e4e4e7',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<i className={`fa ${ic}`} />
</button>
))}
</div>
</div>
{/* Custom icon class */}
<div>
<label style={labelStyle}>Custom Icon Class</label>
<input
type="text"
value={props.icon || ''}
onChange={(e) => setProp((p: IconProps) => { p.icon = e.target.value; })}
placeholder="fa-star"
style={inputStyle}
/>
</div>
{/* Size */}
<div>
<label style={labelStyle}>Size</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{sizePresets.map((s) => (
<button
key={s}
onClick={() => setProp((p: IconProps) => { p.size = s; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.size === s ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{s}
</button>
))}
</div>
</div>
{/* Color */}
<div>
<label style={labelStyle}>Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: IconProps) => { p.color = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.color === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Background shape */}
<div>
<label style={labelStyle}>Background Shape</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{shapePresets.map((s) => (
<button
key={s.value}
onClick={() => setProp((p: IconProps) => { p.bgShape = s.value; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.bgShape === s.value ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{s.label}
</button>
))}
</div>
</div>
{/* Background color */}
{props.bgShape !== 'none' && (
<div>
<label style={labelStyle}>Background Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', '#18181b', '#f1f5f9', '#ffffff'].map((c) => (
<button
key={c}
onClick={() => setProp((p: IconProps) => { 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>
)}
{/* Background size */}
{props.bgShape !== 'none' && (
<div>
<label style={labelStyle}>Background Size</label>
<input
type="text"
value={props.bgSize || '56px'}
onChange={(e) => setProp((p: IconProps) => { p.bgSize = e.target.value; })}
placeholder="56px"
style={inputStyle}
/>
</div>
)}
{/* Link */}
<div>
<label style={labelStyle}>Link URL</label>
<input
type="text"
value={props.link || ''}
onChange={(e) => setProp((p: IconProps) => { p.link = e.target.value; })}
placeholder="https://..."
style={inputStyle}
/>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Icon.craft = {
displayName: 'Icon',
props: {
icon: 'fa-star',
size: '32px',
color: '#3b82f6',
bgColor: 'transparent',
bgShape: 'none',
bgSize: '56px',
link: '',
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: IconSettings,
},
};
/* ---------- HTML export ---------- */
(Icon as any).toHtml = (props: IconProps, _childrenHtml: string) => {
const {
icon = 'fa-star',
size = '32px',
color = '#3b82f6',
bgColor = 'transparent',
bgShape = 'none',
bgSize = '56px',
link = '',
style = {},
} = props;
const iconStyle = cssPropsToString({ fontSize: size, color, lineHeight: '1' });
let iconHtml = `<i class="fa ${icon}"${iconStyle ? ` style="${iconStyle}"` : ''}></i>`;
const hasBg = bgShape !== 'none' && bgColor !== 'transparent';
if (hasBg) {
const bgStyle = cssPropsToString({
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: bgSize,
height: bgSize,
backgroundColor: bgColor,
borderRadius: getBgBorderRadius(bgShape || 'none'),
});
iconHtml = `<div${bgStyle ? ` style="${bgStyle}"` : ''}>${iconHtml}</div>`;
}
if (link) {
iconHtml = `<a href="${link}" style="text-decoration:none;color:inherit">${iconHtml}</a>`;
}
const wrapperStyle = cssPropsToString({ display: 'inline-block', ...style });
return { html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${iconHtml}</div>` };
};

View File

@@ -0,0 +1,418 @@
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { useSiteDesign } from '../../state/SiteDesignContext';
/* ---------- Types ---------- */
interface LogoProps {
type?: 'text' | 'image';
text?: string;
imageSrc?: string;
imageWidth?: string;
href?: string;
fontFamily?: string;
fontSize?: string;
fontWeight?: string;
color?: string;
style?: CSSProperties;
}
/* ---------- Image upload helper ---------- */
async function uploadToWhp(file: File): Promise<string | null> {
const cfg = (window as any).WHP_CONFIG;
if (!cfg) return URL.createObjectURL(file);
const formData = new FormData();
formData.append('file', file);
try {
const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, {
method: 'POST',
headers: { 'X-CSRF-Token': cfg.csrfToken },
body: formData,
});
const data = await resp.json();
if (data.success && data.url) return data.url;
return null;
} catch { return null; }
}
/* ---------- Helper: escape HTML ---------- */
function esc(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/* ---------- Component ---------- */
export const Logo: UserComponent<LogoProps> = ({
type = 'text',
text = 'MySite',
imageSrc = '',
imageWidth = '120px',
href = '/',
fontFamily = 'Inter, sans-serif',
fontSize = '20px',
fontWeight = '700',
color,
style = {},
}) => {
const {
connectors: { connect, drag },
} = useNode();
const { design } = useSiteDesign();
const resolvedColor = color || design.textColor;
return (
<a
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
href={href}
onClick={(e) => e.preventDefault()}
style={{
textDecoration: 'none',
display: 'inline-flex',
alignItems: 'center',
flexShrink: 0,
...style,
}}
>
{type === 'image' && imageSrc ? (
<img
src={imageSrc}
alt={text || 'Logo'}
style={{ width: imageWidth, height: 'auto', display: 'block' }}
/>
) : (
<span style={{
fontWeight,
fontSize,
fontFamily,
color: resolvedColor,
}}>
{text}
</span>
)}
</a>
);
};
/* ---------- Settings panel ---------- */
const LogoSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as LogoProps,
}));
const { design } = useSiteDesign();
const logoType = props.type || 'text';
const fileInputRef = useRef<HTMLInputElement>(null);
const [showBrowser, setShowBrowser] = useState(false);
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
const [browserLoading, setBrowserLoading] = useState(false);
const fontFamilies = [
{ label: 'Inter', value: 'Inter, sans-serif' },
{ label: 'Roboto', value: 'Roboto, sans-serif' },
{ label: 'Poppins', value: 'Poppins, sans-serif' },
{ label: 'Montserrat', value: 'Montserrat, sans-serif' },
{ label: 'Playfair', value: 'Playfair Display, serif' },
{ label: 'Merriweather', value: 'Merriweather, serif' },
{ label: 'Source Code', value: 'Source Code Pro, monospace' },
{ label: 'Open Sans', value: 'Open Sans, sans-serif' },
];
const handleLogoUpload = useCallback(async (file: File) => {
const url = await uploadToWhp(file);
if (url) setProp((p: LogoProps) => { p.imageSrc = url; });
}, [setProp]);
const handleBrowse = useCallback(async () => {
if (showBrowser) { setShowBrowser(false); return; }
const cfg = (window as any).WHP_CONFIG;
if (!cfg) return;
setBrowserLoading(true);
try {
const resp = await fetch(`${cfg.apiUrl}?action=list_assets&site_id=${cfg.siteId}`);
const data = await resp.json();
if (data.success && Array.isArray(data.assets)) {
const images = data.assets.filter((a: any) => (a.type || '').startsWith('image'));
setBrowserAssets(images);
setShowBrowser(true);
}
} catch (e) {
console.error('Browse failed:', e);
} finally {
setBrowserLoading(false);
}
}, [showBrowser]);
/* ---- Shared styles ---- */
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const btnSmall: CSSProperties = {
padding: '2px 6px', fontSize: 11, background: '#27272a', color: '#a1a1aa',
border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
};
const btnActive: CSSProperties = {
...btnSmall, background: '#3b82f6', color: '#fff', borderColor: '#3b82f6',
};
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Type toggle */}
<div>
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Logo Type</label>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => setProp((p: LogoProps) => { p.type = 'text'; })}
style={logoType === 'text' ? btnActive : btnSmall}
>
<i className="fa fa-font" style={{ marginRight: 3 }} />Text
</button>
<button
onClick={() => setProp((p: LogoProps) => { p.type = 'image'; })}
style={logoType === 'image' ? btnActive : btnSmall}
>
<i className="fa fa-image" style={{ marginRight: 3 }} />Image
</button>
</div>
</div>
{logoType === 'text' ? (
<>
<div>
<label style={labelStyle}>Logo Text</label>
<input
type="text"
value={props.text || ''}
onChange={(e) => setProp((p: LogoProps) => { p.text = e.target.value; })}
style={inputStyle}
/>
</div>
<div>
<label style={labelStyle}>Font Family</label>
<select
value={props.fontFamily || 'Inter, sans-serif'}
onChange={(e) => setProp((p: LogoProps) => { p.fontFamily = e.target.value; })}
style={{ ...inputStyle, cursor: 'pointer' }}
>
{fontFamilies.map((f) => (
<option key={f.value} value={f.value}>{f.label}</option>
))}
</select>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Size</label>
<input
type="text"
value={props.fontSize || '20px'}
onChange={(e) => setProp((p: LogoProps) => { p.fontSize = e.target.value; })}
placeholder="20px"
style={inputStyle}
/>
</div>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Weight</label>
<select
value={props.fontWeight || '700'}
onChange={(e) => setProp((p: LogoProps) => { p.fontWeight = e.target.value; })}
style={{ ...inputStyle, cursor: 'pointer' }}
>
<option value="300">Light</option>
<option value="400">Normal</option>
<option value="500">Medium</option>
<option value="600">Semi</option>
<option value="700">Bold</option>
<option value="800">Extra Bold</option>
</select>
</div>
</div>
<div>
<label style={labelStyle}>Color</label>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input
type="color"
value={props.color || design.textColor}
onChange={(e) => setProp((p: LogoProps) => { p.color = e.target.value; })}
style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
/>
<span style={{ fontSize: 10, color: '#71717a' }}>{props.color || 'Auto'}</span>
<button
onClick={() => setProp((p: LogoProps) => { p.color = undefined; })}
style={{ ...btnSmall, fontSize: 9, padding: '2px 4px' }}
title="Reset to auto"
>Auto</button>
</div>
</div>
</>
) : (
<>
{/* Image logo controls */}
{props.imageSrc ? (
<div style={{ borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
<img src={props.imageSrc} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 80, objectFit: 'contain', background: '#18181b' }} />
<button
onClick={() => setProp((p: LogoProps) => { p.imageSrc = ''; })}
style={{ position: 'absolute', top: 4, right: 4, width: 20, height: 20, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
title="Remove image"
>
<i className="fa fa-times" />
</button>
</div>
) : (
<div
style={{ padding: '14px 12px', border: '2px dashed #3f3f46', borderRadius: 6, textAlign: 'center', color: '#71717a', fontSize: 11, cursor: 'pointer' }}
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = '#3b82f6'; }}
onDragLeave={(e) => { e.currentTarget.style.borderColor = '#3f3f46'; }}
onDrop={async (e) => {
e.preventDefault();
e.currentTarget.style.borderColor = '#3f3f46';
const file = e.dataTransfer.files?.[0];
if (file && file.type.startsWith('image/')) await handleLogoUpload(file);
}}
>
<i className="fa fa-cloud-upload" style={{ fontSize: 18, display: 'block', marginBottom: 4, color: '#3b82f6' }} />
Drop logo or click to upload
</div>
)}
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => fileInputRef.current?.click()}
style={{ flex: 1, padding: '6px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#3b82f6', color: '#fff', fontWeight: 500 }}
>
<i className="fa fa-upload" style={{ marginRight: 3 }} /> Upload
</button>
<button
onClick={handleBrowse}
style={{ flex: 1, padding: '6px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: showBrowser ? '#3b82f6' : '#27272a', color: showBrowser ? '#fff' : '#e4e4e7' }}
>
<i className={`fa ${browserLoading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 3 }} /> Browse
</button>
</div>
{/* Browse grid */}
{showBrowser && (
<div style={{ maxHeight: 150, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, background: '#18181b', borderRadius: 6, padding: 4 }}>
{browserAssets.map(asset => (
<div
key={asset.name}
onClick={() => { setProp((p: LogoProps) => { p.imageSrc = asset.url; }); setShowBrowser(false); }}
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1' }}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
>
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
</div>
))}
{browserAssets.length === 0 && (
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '8px 0', margin: 0 }}>No images uploaded yet.</p>
)}
</div>
)}
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={(e) => { const file = e.target.files?.[0]; if (file) handleLogoUpload(file); e.target.value = ''; }} />
{/* URL paste input */}
<div>
<input
type="text"
value={props.imageSrc || ''}
onChange={(e) => setProp((p: LogoProps) => { p.imageSrc = e.target.value; })}
placeholder="Or paste image URL..."
style={{ ...inputStyle, fontSize: 10, color: '#71717a' }}
/>
</div>
<div>
<label style={labelStyle}>Logo Width</label>
<input
type="text"
value={props.imageWidth || '120px'}
onChange={(e) => setProp((p: LogoProps) => { p.imageWidth = e.target.value; })}
placeholder="120px"
style={inputStyle}
/>
</div>
</>
)}
{/* Link URL */}
<div>
<label style={labelStyle}>Link URL</label>
<input
type="text"
value={props.href || '/'}
onChange={(e) => setProp((p: LogoProps) => { p.href = e.target.value; })}
placeholder="/"
style={inputStyle}
/>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Logo.craft = {
displayName: 'Logo',
props: {
type: 'text',
text: 'MySite',
imageSrc: '',
imageWidth: '120px',
href: '/',
fontFamily: 'Inter, sans-serif',
fontSize: '20px',
fontWeight: '700',
color: undefined,
style: {},
} as LogoProps,
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: LogoSettings,
},
};
/* ---------- HTML export ---------- */
(Logo as any).toHtml = (props: LogoProps, _childrenHtml: string) => {
const href = props.href || '/';
let innerHtml: string;
if (props.type === 'image' && props.imageSrc) {
const imgStyle = cssPropsToString({ width: props.imageWidth || '120px', height: 'auto', display: 'block' });
innerHtml = `<img src="${esc(props.imageSrc)}" alt="${esc(props.text || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} />`;
} else {
const spanStyle = cssPropsToString({
fontWeight: props.fontWeight || '700',
fontSize: props.fontSize || '20px',
fontFamily: props.fontFamily || 'Inter, sans-serif',
color: props.color || '#1f2937',
});
innerHtml = `<span${spanStyle ? ` style="${spanStyle}"` : ''}>${esc(props.text || 'MySite')}</span>`;
}
const aStyle = cssPropsToString({
textDecoration: 'none',
display: 'inline-flex',
alignItems: 'center',
flexShrink: '0',
...props.style,
});
return {
html: `<a href="${esc(href)}"${aStyle ? ` style="${aStyle}"` : ''}>${innerHtml}</a>`,
};
};

View File

@@ -0,0 +1,510 @@
import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { usePages } from '../../state/PageContext';
/* ---------- Types ---------- */
interface MenuLink {
text: string;
href: string;
isExternal?: boolean;
isCta?: boolean;
}
interface MenuProps {
links?: MenuLink[];
alignment?: 'left' | 'center' | 'right';
linkColor?: string;
linkHoverColor?: string;
ctaBgColor?: string;
ctaTextColor?: string;
gap?: string;
orientation?: 'horizontal' | 'vertical';
fontSize?: string;
style?: CSSProperties;
}
/* ---------- Defaults ---------- */
const defaultLinks: MenuLink[] = [
{ text: 'Home', href: '/' },
{ text: 'About', href: '#about' },
{ text: 'Services', href: '#services' },
{ text: 'Contact', href: '#contact', isCta: true },
];
/* ---------- Helper: escape HTML ---------- */
function esc(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/* ---------- Component ---------- */
export const Menu: UserComponent<MenuProps> = ({
links = defaultLinks,
alignment = 'right',
linkColor = '#3f3f46',
linkHoverColor = '#3b82f6',
ctaBgColor = '#3b82f6',
ctaTextColor = '#ffffff',
gap = '24px',
orientation = 'horizontal',
fontSize = '14px',
style = {},
}) => {
const {
connectors: { connect, drag },
} = useNode();
const [hoveredLink, setHoveredLink] = useState<number | null>(null);
const justifyMap = { left: 'flex-start', center: 'center', right: 'flex-end' };
return (
<nav
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
display: 'flex',
flexDirection: orientation === 'vertical' ? 'column' : 'row',
alignItems: orientation === 'vertical' ? (alignment === 'center' ? 'center' : alignment === 'right' ? 'flex-end' : 'flex-start') : 'center',
justifyContent: orientation === 'horizontal' ? justifyMap[alignment] : undefined,
gap,
flexWrap: 'wrap',
...style,
}}
>
{links.map((link, i) => (
<a
key={i}
href={link.href}
target={link.isExternal ? '_blank' : undefined}
rel={link.isExternal ? 'noopener noreferrer' : undefined}
onClick={(e) => e.preventDefault()}
onMouseEnter={() => setHoveredLink(i)}
onMouseLeave={() => setHoveredLink(null)}
style={{
textDecoration: 'none',
fontSize,
fontWeight: link.isCta ? '600' : '400',
color: link.isCta
? ctaTextColor
: (hoveredLink === i ? linkHoverColor : linkColor),
backgroundColor: link.isCta ? ctaBgColor : 'transparent',
padding: link.isCta ? '8px 20px' : '0',
borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s',
...(link.isCta && hoveredLink === i ? { filter: 'brightness(1.1)' } : {}),
}}
>
{link.text}
</a>
))}
</nav>
);
};
/* ---------- Settings panel ---------- */
const MenuSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as MenuProps,
}));
const { pages } = usePages();
const links = props.links || defaultLinks;
/* Drag state for reordering */
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
/* ---- Link management ---- */
const updateLink = (index: number, field: keyof MenuLink, value: string | boolean) => {
setProp((p: MenuProps) => {
const updated = [...(p.links || defaultLinks)];
updated[index] = { ...updated[index], [field]: value };
p.links = updated;
});
};
const addLink = (link?: Partial<MenuLink>) => {
setProp((p: MenuProps) => {
p.links = [...(p.links || defaultLinks), { text: 'Link', href: '#', ...link }];
});
};
const removeLink = (index: number) => {
setProp((p: MenuProps) => {
const updated = [...(p.links || defaultLinks)];
updated.splice(index, 1);
p.links = updated;
});
};
const moveLink = (fromIdx: number, toIdx: number) => {
if (fromIdx === toIdx) return;
setProp((p: MenuProps) => {
const updated = [...(p.links || defaultLinks)];
const [moved] = updated.splice(fromIdx, 1);
updated.splice(toIdx, 0, moved);
p.links = updated;
});
};
/* ---- Shared styles ---- */
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const sectionStyle: CSSProperties = {
borderBottom: '1px solid #27272a', paddingBottom: 12,
};
const btnSmall: CSSProperties = {
padding: '2px 6px', fontSize: 11, background: '#27272a', color: '#a1a1aa',
border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
};
const btnActive: CSSProperties = {
...btnSmall, background: '#3b82f6', color: '#fff', borderColor: '#3b82f6',
};
const textColorPresets = ['#1f2937', '#374151', '#3f3f46', '#6b7280', '#ffffff', '#e4e4e7', '#a1a1aa', '#3b82f6'];
const gapPresets = ['8px', '16px', '24px', '32px', '40px'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* ===== Style Section ===== */}
<div style={sectionStyle}>
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Menu Style</label>
{/* Link color */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Link Color</label>
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'center' }}>
{textColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: MenuProps) => { p.linkColor = c; })}
style={{
width: 22, height: 22, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.linkColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
<input
type="color"
value={props.linkColor || '#3f3f46'}
onChange={(e) => setProp((p: MenuProps) => { p.linkColor = e.target.value; })}
style={{ width: 22, height: 22, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
title="Custom color"
/>
</div>
</div>
{/* Hover color */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Hover Color</label>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input
type="color"
value={props.linkHoverColor || '#3b82f6'}
onChange={(e) => setProp((p: MenuProps) => { p.linkHoverColor = e.target.value; })}
style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
/>
<span style={{ fontSize: 10, color: '#71717a' }}>{props.linkHoverColor || '#3b82f6'}</span>
</div>
</div>
{/* CTA button colors */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>CTA Button</label>
<div style={{ display: 'flex', gap: 8 }}>
<div>
<span style={{ fontSize: 9, color: '#71717a' }}>BG</span>
<input
type="color"
value={props.ctaBgColor || '#3b82f6'}
onChange={(e) => setProp((p: MenuProps) => { p.ctaBgColor = e.target.value; })}
style={{ display: 'block', width: 28, height: 20, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
/>
</div>
<div>
<span style={{ fontSize: 9, color: '#71717a' }}>Text</span>
<input
type="color"
value={props.ctaTextColor || '#ffffff'}
onChange={(e) => setProp((p: MenuProps) => { p.ctaTextColor = e.target.value; })}
style={{ display: 'block', width: 28, height: 20, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
/>
</div>
</div>
</div>
{/* Font size */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Font Size</label>
<input
type="text"
value={props.fontSize || '14px'}
onChange={(e) => setProp((p: MenuProps) => { p.fontSize = e.target.value; })}
placeholder="14px"
style={inputStyle}
/>
</div>
{/* Alignment */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Alignment</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['left', 'center', 'right'] as const).map((a) => (
<button
key={a}
onClick={() => setProp((p: MenuProps) => { p.alignment = a; })}
style={(props.alignment || 'right') === a ? btnActive : btnSmall}
>
{a.charAt(0).toUpperCase() + a.slice(1)}
</button>
))}
</div>
</div>
{/* Orientation */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Orientation</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['horizontal', 'vertical'] as const).map((o) => (
<button
key={o}
onClick={() => setProp((p: MenuProps) => { p.orientation = o; })}
style={(props.orientation || 'horizontal') === o ? btnActive : btnSmall}
>
{o.charAt(0).toUpperCase() + o.slice(1)}
</button>
))}
</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: MenuProps) => { p.gap = g; })}
style={(props.gap || '24px') === g ? btnActive : btnSmall}
>
{g}
</button>
))}
</div>
</div>
</div>
{/* ===== Links Section ===== */}
<div>
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Links</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{links.map((link, i) => (
<div
key={i}
draggable
onDragStart={() => setDragIdx(i)}
onDragOver={(e) => { e.preventDefault(); setDragOverIdx(i); }}
onDragEnd={() => {
if (dragIdx !== null && dragOverIdx !== null) {
moveLink(dragIdx, dragOverIdx);
}
setDragIdx(null);
setDragOverIdx(null);
}}
style={{
background: dragOverIdx === i && dragIdx !== null && dragIdx !== i ? '#1e293b' : '#1e1e22',
borderRadius: 6,
padding: 8,
display: 'flex',
flexDirection: 'column',
gap: 4,
border: dragOverIdx === i && dragIdx !== null && dragIdx !== i ? '1px solid #3b82f6' : '1px solid transparent',
transition: 'background 0.1s, border-color 0.1s',
}}
>
{/* Row 1: drag handle + text + delete */}
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<span
style={{ cursor: 'grab', color: '#52525b', fontSize: 12, padding: '0 2px', userSelect: 'none', flexShrink: 0 }}
title="Drag to reorder"
>
<i className="fa fa-bars" />
</span>
<input
type="text"
value={link.text}
onChange={(e) => updateLink(i, 'text', e.target.value)}
placeholder="Text"
style={{ ...inputStyle, flex: 1 }}
/>
<button
onClick={() => removeLink(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flexShrink: 0 }}
title="Delete link"
>
<i className="fa fa-trash" />
</button>
</div>
{/* Row 2: URL */}
<input
type="text"
value={link.href}
onChange={(e) => updateLink(i, 'href', e.target.value)}
placeholder="URL (e.g. /about or https://...)"
style={inputStyle}
/>
{/* Row 3: checkboxes */}
<div style={{ display: 'flex', gap: 8 }}>
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
<input type="checkbox" checked={!!link.isExternal} onChange={(e) => updateLink(i, 'isExternal', e.target.checked)} />
External
</label>
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
<input type="checkbox" checked={!!link.isCta} onChange={(e) => updateLink(i, 'isCta', e.target.checked)} />
CTA
</label>
</div>
</div>
))}
</div>
{/* Add link button */}
<button
onClick={() => addLink()}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Link
</button>
{/* Add page dropdown */}
<select
onChange={(e) => {
const page = pages.find(p => p.id === e.target.value);
if (page) {
addLink({
text: page.name,
href: page.slug === 'index' ? '/' : page.slug,
isExternal: false,
isCta: false,
});
}
e.target.value = '';
}}
value=""
style={{
marginTop: 4, width: '100%', padding: '6px', fontSize: 11,
background: '#1e293b', color: '#93c5fd',
border: '1px solid #334155', borderRadius: 4, cursor: 'pointer',
}}
>
<option value="">+ Add Page...</option>
{pages.map(p => (
<option key={p.id} value={p.id}>
{p.name} ({p.slug === 'index' ? '/' : p.slug})
</option>
))}
</select>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Menu.craft = {
displayName: 'Menu',
props: {
links: defaultLinks,
alignment: 'right',
linkColor: '#3f3f46',
linkHoverColor: '#3b82f6',
ctaBgColor: '#3b82f6',
ctaTextColor: '#ffffff',
gap: '24px',
orientation: 'horizontal',
fontSize: '14px',
style: {},
} as MenuProps,
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: MenuSettings,
},
};
/* ---------- HTML export ---------- */
(Menu as any).toHtml = (props: MenuProps, _childrenHtml: string) => {
const linkCol = props.linkColor || '#3f3f46';
const hoverCol = props.linkHoverColor || '#3b82f6';
const ctaBg = props.ctaBgColor || '#3b82f6';
const ctaText = props.ctaTextColor || '#ffffff';
const gap = props.gap || '24px';
const orientation = props.orientation || 'horizontal';
const alignment = props.alignment || 'right';
const fSize = props.fontSize || '14px';
const justifyMap: Record<string, string> = { left: 'flex-start', center: 'center', right: 'flex-end' };
const navStyle = cssPropsToString({
display: 'flex',
flexDirection: orientation === 'vertical' ? 'column' : 'row',
alignItems: orientation === 'vertical'
? (alignment === 'center' ? 'center' : alignment === 'right' ? 'flex-end' : 'flex-start')
: 'center',
justifyContent: orientation === 'horizontal' ? justifyMap[alignment] : undefined,
gap,
flexWrap: 'wrap',
...props.style,
});
const links = props.links || defaultLinks;
// Unique ID suffix for scoped CSS
const scopeId = `menu-${Math.random().toString(36).slice(2, 8)}`;
const linksHtml = links.map((link) => {
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
const cls = link.isCta ? `${scopeId}-cta` : `${scopeId}-link`;
const linkStyle = cssPropsToString({
textDecoration: 'none',
fontSize: fSize,
fontWeight: link.isCta ? '600' : '400',
color: link.isCta ? ctaText : linkCol,
backgroundColor: link.isCta ? ctaBg : 'transparent',
padding: link.isCta ? '8px 20px' : '0',
borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s',
});
return `<a href="${esc(link.href)}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${esc(link.text)}</a>`;
}).join('\n ');
const hoverCss = `<style>
.${scopeId}-link:hover { color: ${hoverCol} !important; }
.${scopeId}-cta:hover { filter: brightness(1.1); }
</style>`;
return {
html: `${hoverCss}
<nav${navStyle ? ` style="${navStyle}"` : ''}>
${linksHtml}
</nav>`,
};
};

View File

@@ -0,0 +1,929 @@
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { usePages } from '../../state/PageContext';
import { useSiteDesign } from '../../state/SiteDesignContext';
/* ---------- Types ---------- */
interface NavLink {
text: string;
href: string;
isExternal?: boolean;
isCta?: boolean;
}
interface NavbarProps {
logoType?: 'text' | 'image';
logoText?: string;
logoImage?: string;
logoWidth?: string;
logoUrl?: string;
logoFontFamily?: string;
logoFontSize?: string;
logoColor?: string;
links?: NavLink[];
backgroundColor?: string;
textColor?: string;
hoverColor?: string;
ctaColor?: string;
ctaTextColor?: string;
padding?: string;
navAlignment?: 'left' | 'center' | 'right' | 'space-between';
isSticky?: boolean;
showMobileMenu?: boolean;
style?: CSSProperties;
}
/* ---------- Defaults ---------- */
const defaultLinks: NavLink[] = [
{ text: 'Home', href: '/' },
{ text: 'About', href: '#about' },
{ text: 'Services', href: '#services' },
{ text: 'Contact', href: '#contact', isCta: true },
];
const PADDING_PRESETS = [
{ label: 'Compact', value: '8px 16px' },
{ label: 'Normal', value: '16px 24px' },
{ label: 'Relaxed', value: '20px 32px' },
{ label: 'Spacious', value: '24px 48px' },
];
/* ---------- Image upload helper (same as ImageBlock) ---------- */
async function uploadToWhp(file: File): Promise<string | null> {
const cfg = (window as any).WHP_CONFIG;
if (!cfg) return URL.createObjectURL(file);
const formData = new FormData();
formData.append('file', file);
try {
const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, {
method: 'POST',
headers: { 'X-CSRF-Token': cfg.csrfToken },
body: formData,
});
const data = await resp.json();
if (data.success && data.url) return data.url;
return null;
} catch { return null; }
}
/* ---------- Helper: escape HTML ---------- */
function esc(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/* ---------- Component ---------- */
export const Navbar: UserComponent<NavbarProps> = ({
logoType = 'text',
logoText = 'MySite',
logoImage = '',
logoWidth = '120px',
logoUrl = '/',
logoFontFamily = 'Inter, sans-serif',
logoFontSize = '20px',
logoColor,
links = defaultLinks,
backgroundColor = '#ffffff',
textColor = '#3f3f46',
hoverColor = '#3b82f6',
ctaColor = '#3b82f6',
ctaTextColor = '#ffffff',
padding = '16px 24px',
navAlignment = 'space-between',
isSticky = false,
showMobileMenu = false,
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const { design } = useSiteDesign();
const resolvedLogoColor = logoColor || (backgroundColor === '#ffffff' || backgroundColor === '#f8fafc' || backgroundColor === '#f9fafb' ? design.textColor : '#ffffff');
const resolvedTextColor = textColor || (backgroundColor === '#ffffff' || backgroundColor === '#f8fafc' || backgroundColor === '#f9fafb' ? '#3f3f46' : '#e4e4e7');
const [hoveredLink, setHoveredLink] = useState<number | null>(null);
return (
<nav
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: navAlignment,
padding,
backgroundColor,
...(isSticky ? { position: 'sticky' as const, top: 0, zIndex: 1000 } : {}),
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
{/* Logo */}
<a
href={logoUrl}
onClick={(e) => e.preventDefault()}
style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}
>
{logoType === 'image' && logoImage ? (
<img
src={logoImage}
alt={logoText || 'Logo'}
style={{ width: logoWidth, height: 'auto', display: 'block' }}
/>
) : (
<span style={{
fontWeight: '700',
fontSize: logoFontSize,
fontFamily: logoFontFamily,
color: resolvedLogoColor,
}}>
{logoText}
</span>
)}
</a>
{/* Links */}
<div style={{ display: 'flex', alignItems: 'center', gap: '24px' }}>
{showMobileMenu && (
<div
style={{
display: 'none', /* Hidden in editor, shown via media query in export */
flexDirection: 'column',
gap: '4px',
cursor: 'pointer',
padding: '4px',
}}
className="navbar-hamburger"
>
<span style={{ display: 'block', width: '24px', height: '2px', backgroundColor: resolvedTextColor }} />
<span style={{ display: 'block', width: '24px', height: '2px', backgroundColor: resolvedTextColor }} />
<span style={{ display: 'block', width: '24px', height: '2px', backgroundColor: resolvedTextColor }} />
</div>
)}
{links.map((link, i) => (
<a
key={i}
href={link.href}
target={link.isExternal ? '_blank' : undefined}
rel={link.isExternal ? 'noopener noreferrer' : undefined}
onClick={(e) => e.preventDefault()}
onMouseEnter={() => setHoveredLink(i)}
onMouseLeave={() => setHoveredLink(null)}
style={{
textDecoration: 'none',
fontSize: '14px',
fontWeight: link.isCta ? '600' : '400',
color: link.isCta
? ctaTextColor
: (hoveredLink === i ? hoverColor : resolvedTextColor),
backgroundColor: link.isCta ? ctaColor : 'transparent',
padding: link.isCta ? '8px 20px' : '0',
borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s',
...(link.isCta && hoveredLink === i ? { filter: 'brightness(1.1)' } : {}),
}}
>
{link.text}
</a>
))}
</div>
</nav>
);
};
/* ---------- Settings panel ---------- */
const NavbarSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as NavbarProps,
}));
const { pages } = usePages();
const { design } = useSiteDesign();
const links = props.links || defaultLinks;
const logoType = props.logoType || 'text';
const fileInputRef = useRef<HTMLInputElement>(null);
const [showBrowser, setShowBrowser] = useState(false);
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
const [browserLoading, setBrowserLoading] = useState(false);
/* Drag state for reordering */
const [dragIdx, setDragIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
const bgPresets = ['#ffffff', '#f8fafc', '#f9fafb', '#18181b', '#0f172a', '#1e293b', '#1f2937', '#111827'];
const textColorPresets = ['#1f2937', '#374151', '#3f3f46', '#6b7280', '#ffffff', '#e4e4e7', '#a1a1aa', '#3b82f6'];
const fontFamilies = [
{ label: 'Inter', value: 'Inter, sans-serif' },
{ label: 'Roboto', value: 'Roboto, sans-serif' },
{ label: 'Poppins', value: 'Poppins, sans-serif' },
{ label: 'Montserrat', value: 'Montserrat, sans-serif' },
{ label: 'Playfair', value: 'Playfair Display, serif' },
{ label: 'Merriweather', value: 'Merriweather, serif' },
];
/* ---- Link management ---- */
const updateLink = (index: number, field: keyof NavLink, value: string | boolean) => {
setProp((p: NavbarProps) => {
const updated = [...(p.links || defaultLinks)];
updated[index] = { ...updated[index], [field]: value };
p.links = updated;
});
};
const addLink = (link?: Partial<NavLink>) => {
setProp((p: NavbarProps) => {
p.links = [...(p.links || defaultLinks), { text: 'Link', href: '#', ...link }];
});
};
const removeLink = (index: number) => {
setProp((p: NavbarProps) => {
const updated = [...(p.links || defaultLinks)];
updated.splice(index, 1);
p.links = updated;
});
};
const moveLink = (fromIdx: number, toIdx: number) => {
if (fromIdx === toIdx) return;
setProp((p: NavbarProps) => {
const updated = [...(p.links || defaultLinks)];
const [moved] = updated.splice(fromIdx, 1);
updated.splice(toIdx, 0, moved);
p.links = updated;
});
};
/* ---- Image upload for logo ---- */
const handleLogoUpload = useCallback(async (file: File) => {
const url = await uploadToWhp(file);
if (url) setProp((p: NavbarProps) => { p.logoImage = url; });
}, [setProp]);
const handleBrowse = useCallback(async () => {
if (showBrowser) { setShowBrowser(false); return; }
const cfg = (window as any).WHP_CONFIG;
if (!cfg) return;
setBrowserLoading(true);
try {
const resp = await fetch(`${cfg.apiUrl}?action=list_assets&site_id=${cfg.siteId}`);
const data = await resp.json();
if (data.success && Array.isArray(data.assets)) {
const images = data.assets.filter((a: any) => (a.type || '').startsWith('image'));
setBrowserAssets(images);
setShowBrowser(true);
}
} catch (e) {
console.error('Browse failed:', e);
} finally {
setBrowserLoading(false);
}
}, [showBrowser]);
/* ---- Shared styles ---- */
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const sectionStyle: CSSProperties = {
borderBottom: '1px solid #27272a', paddingBottom: 12,
};
const swatchStyle = (color: string, active: boolean): CSSProperties => ({
width: 22, height: 22, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: color, cursor: 'pointer',
outline: active ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
});
const btnSmall: CSSProperties = {
padding: '2px 6px', fontSize: 11, background: '#27272a', color: '#a1a1aa',
border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer',
};
const btnActive: CSSProperties = {
...btnSmall, background: '#3b82f6', color: '#fff', borderColor: '#3b82f6',
};
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* ===== Logo Section ===== */}
<div style={sectionStyle}>
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Logo</label>
{/* Logo type toggle */}
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
<button
onClick={() => setProp((p: NavbarProps) => { p.logoType = 'text'; })}
style={logoType === 'text' ? btnActive : btnSmall}
>
<i className="fa fa-font" style={{ marginRight: 3 }} />Text
</button>
<button
onClick={() => setProp((p: NavbarProps) => { p.logoType = 'image'; })}
style={logoType === 'image' ? btnActive : btnSmall}
>
<i className="fa fa-image" style={{ marginRight: 3 }} />Image
</button>
</div>
{logoType === 'text' ? (
<>
{/* Text logo controls */}
<div style={{ marginBottom: 6 }}>
<label style={labelStyle}>Logo Text</label>
<input
type="text"
value={props.logoText || ''}
onChange={(e) => setProp((p: NavbarProps) => { p.logoText = e.target.value; })}
style={inputStyle}
/>
</div>
<div style={{ marginBottom: 6 }}>
<label style={labelStyle}>Font Family</label>
<select
value={props.logoFontFamily || 'Inter, sans-serif'}
onChange={(e) => setProp((p: NavbarProps) => { p.logoFontFamily = e.target.value; })}
style={{ ...inputStyle, cursor: 'pointer' }}
>
{fontFamilies.map((f) => (
<option key={f.value} value={f.value}>{f.label}</option>
))}
</select>
</div>
<div style={{ display: 'flex', gap: 6, marginBottom: 6 }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Size</label>
<input
type="text"
value={props.logoFontSize || '20px'}
onChange={(e) => setProp((p: NavbarProps) => { p.logoFontSize = e.target.value; })}
placeholder="20px"
style={inputStyle}
/>
</div>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Color</label>
<div style={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<input
type="color"
value={props.logoColor || design.textColor}
onChange={(e) => setProp((p: NavbarProps) => { p.logoColor = e.target.value; })}
style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
/>
<button
onClick={() => setProp((p: NavbarProps) => { p.logoColor = undefined; })}
style={{ ...btnSmall, fontSize: 9, padding: '2px 4px' }}
title="Reset to auto"
>Auto</button>
</div>
</div>
</div>
</>
) : (
<>
{/* Image logo controls */}
{props.logoImage ? (
<div style={{ marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
<img src={props.logoImage} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 80, objectFit: 'contain', background: '#18181b' }} />
<button
onClick={() => setProp((p: NavbarProps) => { p.logoImage = ''; })}
style={{ position: 'absolute', top: 4, right: 4, width: 20, height: 20, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
title="Remove image"
>
<i className="fa fa-times" />
</button>
</div>
) : (
<div
style={{ padding: '14px 12px', border: '2px dashed #3f3f46', borderRadius: 6, textAlign: 'center', color: '#71717a', fontSize: 11, cursor: 'pointer', marginBottom: 8 }}
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = '#3b82f6'; }}
onDragLeave={(e) => { e.currentTarget.style.borderColor = '#3f3f46'; }}
onDrop={async (e) => {
e.preventDefault();
e.currentTarget.style.borderColor = '#3f3f46';
const file = e.dataTransfer.files?.[0];
if (file && file.type.startsWith('image/')) await handleLogoUpload(file);
}}
>
<i className="fa fa-cloud-upload" style={{ fontSize: 18, display: 'block', marginBottom: 4, color: '#3b82f6' }} />
Drop logo or click to upload
</div>
)}
<div style={{ display: 'flex', gap: 4, marginBottom: 6 }}>
<button
onClick={() => fileInputRef.current?.click()}
style={{ flex: 1, padding: '6px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#3b82f6', color: '#fff', fontWeight: 500 }}
>
<i className="fa fa-upload" style={{ marginRight: 3 }} /> Upload
</button>
<button
onClick={handleBrowse}
style={{ flex: 1, padding: '6px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: showBrowser ? '#3b82f6' : '#27272a', color: showBrowser ? '#fff' : '#e4e4e7' }}
>
<i className={`fa ${browserLoading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 3 }} /> Browse
</button>
</div>
{/* Browse grid */}
{showBrowser && (
<div style={{ maxHeight: 150, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, marginBottom: 6, background: '#18181b', borderRadius: 6, padding: 4 }}>
{browserAssets.map(asset => (
<div
key={asset.name}
onClick={() => { setProp((p: NavbarProps) => { p.logoImage = asset.url; }); setShowBrowser(false); }}
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1' }}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
>
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
</div>
))}
{browserAssets.length === 0 && (
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '8px 0', margin: 0 }}>No images uploaded yet.</p>
)}
</div>
)}
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={(e) => { const file = e.target.files?.[0]; if (file) handleLogoUpload(file); e.target.value = ''; }} />
{/* URL input */}
<div style={{ marginBottom: 6 }}>
<input
type="text"
value={props.logoImage || ''}
onChange={(e) => setProp((p: NavbarProps) => { p.logoImage = e.target.value; })}
placeholder="Or paste image URL..."
style={{ ...inputStyle, fontSize: 10, color: '#71717a' }}
/>
</div>
<div>
<label style={labelStyle}>Logo Width</label>
<input
type="text"
value={props.logoWidth || '120px'}
onChange={(e) => setProp((p: NavbarProps) => { p.logoWidth = e.target.value; })}
placeholder="120px"
style={inputStyle}
/>
</div>
</>
)}
{/* Logo link URL (shared) */}
<div style={{ marginTop: 6 }}>
<label style={labelStyle}>Logo Link URL</label>
<input
type="text"
value={props.logoUrl || '/'}
onChange={(e) => setProp((p: NavbarProps) => { p.logoUrl = e.target.value; })}
placeholder="/"
style={inputStyle}
/>
</div>
</div>
{/* ===== Nav Style Section ===== */}
<div style={sectionStyle}>
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Nav Style</label>
{/* Background color */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Background</label>
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'center' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: NavbarProps) => { p.backgroundColor = c; })}
style={swatchStyle(c, props.backgroundColor === c)}
/>
))}
<input
type="color"
value={props.backgroundColor || '#ffffff'}
onChange={(e) => setProp((p: NavbarProps) => { p.backgroundColor = e.target.value; })}
style={{ width: 22, height: 22, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
title="Custom color"
/>
</div>
</div>
{/* Text color */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Text Color</label>
<div style={{ display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'center' }}>
{textColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: NavbarProps) => { p.textColor = c; })}
style={swatchStyle(c, props.textColor === c)}
/>
))}
<input
type="color"
value={props.textColor || '#3f3f46'}
onChange={(e) => setProp((p: NavbarProps) => { p.textColor = e.target.value; })}
style={{ width: 22, height: 22, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
title="Custom color"
/>
</div>
</div>
{/* Link hover color */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Hover Color</label>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input
type="color"
value={props.hoverColor || '#3b82f6'}
onChange={(e) => setProp((p: NavbarProps) => { p.hoverColor = e.target.value; })}
style={{ width: 28, height: 24, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
/>
<span style={{ fontSize: 10, color: '#71717a' }}>{props.hoverColor || '#3b82f6'}</span>
</div>
</div>
{/* CTA button colors */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>CTA Button</label>
<div style={{ display: 'flex', gap: 8 }}>
<div>
<span style={{ fontSize: 9, color: '#71717a' }}>BG</span>
<input
type="color"
value={props.ctaColor || '#3b82f6'}
onChange={(e) => setProp((p: NavbarProps) => { p.ctaColor = e.target.value; })}
style={{ display: 'block', width: 28, height: 20, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
/>
</div>
<div>
<span style={{ fontSize: 9, color: '#71717a' }}>Text</span>
<input
type="color"
value={props.ctaTextColor || '#ffffff'}
onChange={(e) => setProp((p: NavbarProps) => { p.ctaTextColor = e.target.value; })}
style={{ display: 'block', width: 28, height: 20, padding: 0, border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer', background: 'none' }}
/>
</div>
</div>
</div>
{/* Padding presets */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Padding</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{PADDING_PRESETS.map((p) => (
<button
key={p.label}
onClick={() => setProp((pr: NavbarProps) => { pr.padding = p.value; })}
style={props.padding === p.value ? btnActive : btnSmall}
>
{p.label}
</button>
))}
</div>
</div>
{/* Alignment */}
<div style={{ marginBottom: 8 }}>
<label style={labelStyle}>Alignment</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['left', 'center', 'right', 'space-between'] as const).map((a) => (
<button
key={a}
onClick={() => setProp((p: NavbarProps) => { p.navAlignment = a; })}
style={props.navAlignment === a || (!props.navAlignment && a === 'space-between') ? btnActive : btnSmall}
>
{a === 'space-between' ? 'Spread' : a.charAt(0).toUpperCase() + a.slice(1)}
</button>
))}
</div>
</div>
{/* Sticky toggle */}
<div style={{ marginBottom: 8, display: 'flex', gap: 8 }}>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<input
type="checkbox"
checked={!!props.isSticky}
onChange={(e) => setProp((p: NavbarProps) => { p.isSticky = e.target.checked; })}
/>
Sticky
</label>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<input
type="checkbox"
checked={!!props.showMobileMenu}
onChange={(e) => setProp((p: NavbarProps) => { p.showMobileMenu = e.target.checked; })}
/>
Mobile Menu
</label>
</div>
{/* Design token quick apply */}
<div>
<label style={labelStyle}>Apply Design Token</label>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => setProp((p: NavbarProps) => {
p.backgroundColor = '#ffffff';
p.textColor = design.textColor;
p.hoverColor = design.primaryColor;
p.ctaColor = design.primaryColor;
p.ctaTextColor = '#ffffff';
})}
style={btnSmall}
>
<i className="fa fa-sun-o" style={{ marginRight: 3 }} />Light
</button>
<button
onClick={() => setProp((p: NavbarProps) => {
p.backgroundColor = '#0f172a';
p.textColor = '#e4e4e7';
p.hoverColor = design.primaryColor;
p.ctaColor = design.primaryColor;
p.ctaTextColor = '#ffffff';
p.logoColor = '#ffffff';
})}
style={btnSmall}
>
<i className="fa fa-moon-o" style={{ marginRight: 3 }} />Dark
</button>
</div>
</div>
</div>
{/* ===== Links Section ===== */}
<div>
<label style={{ ...labelStyle, fontWeight: 600, fontSize: 12, marginBottom: 8 }}>Links</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{links.map((link, i) => (
<div
key={i}
draggable
onDragStart={() => setDragIdx(i)}
onDragOver={(e) => { e.preventDefault(); setDragOverIdx(i); }}
onDragEnd={() => {
if (dragIdx !== null && dragOverIdx !== null) {
moveLink(dragIdx, dragOverIdx);
}
setDragIdx(null);
setDragOverIdx(null);
}}
style={{
background: dragOverIdx === i && dragIdx !== null && dragIdx !== i ? '#1e293b' : '#1e1e22',
borderRadius: 6,
padding: 8,
display: 'flex',
flexDirection: 'column',
gap: 4,
border: dragOverIdx === i && dragIdx !== null && dragIdx !== i ? '1px solid #3b82f6' : '1px solid transparent',
transition: 'background 0.1s, border-color 0.1s',
}}
>
{/* Row 1: drag handle + text + delete */}
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<span
style={{ cursor: 'grab', color: '#52525b', fontSize: 12, padding: '0 2px', userSelect: 'none', flexShrink: 0 }}
title="Drag to reorder"
>
<i className="fa fa-bars" />
</span>
<input
type="text"
value={link.text}
onChange={(e) => updateLink(i, 'text', e.target.value)}
placeholder="Text"
style={{ ...inputStyle, flex: 1 }}
/>
<button
onClick={() => removeLink(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flexShrink: 0 }}
title="Delete link"
>
<i className="fa fa-trash" />
</button>
</div>
{/* Row 2: URL */}
<input
type="text"
value={link.href}
onChange={(e) => updateLink(i, 'href', e.target.value)}
placeholder="URL (e.g. /about or https://...)"
style={inputStyle}
/>
{/* Row 3: checkboxes */}
<div style={{ display: 'flex', gap: 8 }}>
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
<input type="checkbox" checked={!!link.isExternal} onChange={(e) => updateLink(i, 'isExternal', e.target.checked)} />
External
</label>
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
<input type="checkbox" checked={!!link.isCta} onChange={(e) => updateLink(i, 'isCta', e.target.checked)} />
CTA
</label>
</div>
</div>
))}
</div>
{/* Add link button */}
<button
onClick={() => addLink()}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Link
</button>
{/* Add page dropdown */}
<select
onChange={(e) => {
const page = pages.find(p => p.id === e.target.value);
if (page) {
addLink({
text: page.name,
href: page.slug === 'index' ? '/' : page.slug,
isExternal: false,
isCta: false,
});
}
e.target.value = '';
}}
value=""
style={{
marginTop: 4, width: '100%', padding: '6px', fontSize: 11,
background: '#1e293b', color: '#93c5fd',
border: '1px solid #334155', borderRadius: 4, cursor: 'pointer',
}}
>
<option value="">+ Add Page...</option>
{pages.map(p => (
<option key={p.id} value={p.id}>
{p.name} ({p.slug === 'index' ? '/' : p.slug})
</option>
))}
</select>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Navbar.craft = {
displayName: 'Navbar',
props: {
logoType: 'text',
logoText: 'MySite',
logoImage: '',
logoWidth: '120px',
logoUrl: '/',
logoFontFamily: 'Inter, sans-serif',
logoFontSize: '20px',
logoColor: undefined,
links: defaultLinks,
backgroundColor: '#ffffff',
textColor: '#3f3f46',
hoverColor: '#3b82f6',
ctaColor: '#3b82f6',
ctaTextColor: '#ffffff',
padding: '16px 24px',
navAlignment: 'space-between',
isSticky: false,
showMobileMenu: false,
style: {
borderBottom: '1px solid #e4e4e7',
},
} as NavbarProps,
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: NavbarSettings,
},
};
/* ---------- HTML export ---------- */
(Navbar as any).toHtml = (props: NavbarProps, _childrenHtml: string) => {
const bgColor = props.backgroundColor || '#ffffff';
const textCol = props.textColor || '#3f3f46';
const hoverCol = props.hoverColor || '#3b82f6';
const ctaCol = props.ctaColor || '#3b82f6';
const ctaTextCol = props.ctaTextColor || '#ffffff';
const pad = props.padding || '16px 24px';
const alignment = props.navAlignment || 'space-between';
const sticky = props.isSticky;
const mobile = props.showMobileMenu;
const logoUrl = props.logoUrl || '/';
const navStyle = cssPropsToString({
display: 'flex',
alignItems: 'center',
justifyContent: alignment,
padding: pad,
backgroundColor: bgColor,
...(sticky ? { position: 'sticky', top: '0', zIndex: '1000' } : {}),
...props.style,
});
// Logo HTML
let logoHtml: string;
if (props.logoType === 'image' && props.logoImage) {
const imgStyle = cssPropsToString({ width: props.logoWidth || '120px', height: 'auto', display: 'block' });
logoHtml = `<a href="${esc(logoUrl)}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><img src="${esc(props.logoImage)}" alt="${esc(props.logoText || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} /></a>`;
} else {
const logoStyle = cssPropsToString({
fontWeight: '700',
fontSize: props.logoFontSize || '20px',
fontFamily: props.logoFontFamily || 'Inter, sans-serif',
color: props.logoColor || textCol,
});
logoHtml = `<a href="${esc(logoUrl)}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><span${logoStyle ? ` style="${logoStyle}"` : ''}>${esc(props.logoText || 'MySite')}</span></a>`;
}
// Links HTML
const links = props.links || defaultLinks;
const linksHtml = links.map((link) => {
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
const linkStyle = cssPropsToString({
textDecoration: 'none',
fontSize: '14px',
fontWeight: link.isCta ? '600' : '400',
color: link.isCta ? ctaTextCol : textCol,
backgroundColor: link.isCta ? ctaCol : 'transparent',
padding: link.isCta ? '8px 20px' : '0',
borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s',
});
return `<a href="${esc(link.href)}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${esc(link.text)}</a>`;
}).join('\n ');
// Hamburger HTML for mobile
const hamburgerHtml = mobile
? `\n <button class="navbar-hamburger" onclick="this.parentElement.querySelector('.navbar-links').classList.toggle('navbar-open')" style="display:none;background:none;border:none;cursor:pointer;padding:4px;flex-direction:column;gap:4px">
<span style="display:block;width:24px;height:2px;background-color:${esc(textCol)}"></span>
<span style="display:block;width:24px;height:2px;background-color:${esc(textCol)}"></span>
<span style="display:block;width:24px;height:2px;background-color:${esc(textCol)}"></span>
</button>`
: '';
// Hover CSS
const hoverCss = `<style>
.navbar-link:hover { color: ${hoverCol} !important; }
.navbar-cta:hover { filter: brightness(1.1); }${mobile ? `
@media (max-width: 768px) {
.navbar-hamburger { display: flex !important; }
.navbar-links { display: none !important; position: absolute; top: 100%; left: 0; right: 0; flex-direction: column !important; background-color: ${bgColor}; padding: 12px 24px; gap: 12px !important; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.navbar-links.navbar-open { display: flex !important; }
}` : ''}
</style>`;
// Add CSS class to each link for hover
const linksHtmlWithClass = links.map((link) => {
const target = link.isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
const cls = link.isCta ? 'navbar-cta' : 'navbar-link';
const linkStyle = cssPropsToString({
textDecoration: 'none',
fontSize: '14px',
fontWeight: link.isCta ? '600' : '400',
color: link.isCta ? ctaTextCol : textCol,
backgroundColor: link.isCta ? ctaCol : 'transparent',
padding: link.isCta ? '8px 20px' : '0',
borderRadius: link.isCta ? '6px' : '0',
transition: 'color 0.15s, background-color 0.15s',
});
return `<a href="${esc(link.href)}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${esc(link.text)}</a>`;
}).join('\n ');
return {
html: `${hoverCss}
<nav${navStyle ? ` style="${navStyle}${mobile ? ';position:relative' : ''}"` : ''}>
${logoHtml}${hamburgerHtml}
<div class="navbar-links" style="display:flex;align-items:center;gap:24px">
${linksHtmlWithClass}
</div>
</nav>`,
};
};

View File

@@ -0,0 +1,204 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface SearchBarProps {
placeholder?: string;
buttonText?: string;
showButton?: boolean;
style?: CSSProperties;
}
export const SearchBar: UserComponent<SearchBarProps> = ({
placeholder = 'Search...',
buttonText = 'Search',
showButton = true,
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<form
ref={(ref: HTMLFormElement | null): void => { if (ref) connect(drag(ref)); }}
role="search"
onSubmit={(e) => e.preventDefault()}
style={{
display: 'flex',
alignItems: 'center',
maxWidth: '560px',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
<div style={{ position: 'relative', flex: 1 }}>
<i
className="fa fa-search"
style={{
position: 'absolute',
left: '14px',
top: '50%',
transform: 'translateY(-50%)',
color: '#9ca3af',
fontSize: '14px',
pointerEvents: 'none',
}}
/>
<input
type="search"
placeholder={placeholder}
style={{
width: '100%',
padding: '12px 16px 12px 40px',
fontSize: '15px',
fontFamily: 'Inter, sans-serif',
border: '1px solid #d1d5db',
borderRadius: showButton ? '8px 0 0 8px' : '8px',
backgroundColor: '#ffffff',
color: '#1f2937',
outline: 'none',
boxSizing: 'border-box',
}}
/>
</div>
{showButton && (
<button
type="submit"
style={{
padding: '12px 20px',
fontSize: '15px',
fontWeight: '600',
fontFamily: 'Inter, sans-serif',
color: '#ffffff',
backgroundColor: '#3b82f6',
border: 'none',
borderRadius: '0 8px 8px 0',
cursor: 'pointer',
whiteSpace: 'nowrap',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
<i className="fa fa-search" style={{ fontSize: '13px' }} />
{buttonText}
</button>
)}
</form>
);
};
/* ---------- Settings panel ---------- */
const SearchBarSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as SearchBarProps,
}));
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
const inputStyle: CSSProperties = {
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
};
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Placeholder */}
<div>
<label style={labelStyle}>Placeholder</label>
<input
type="text"
value={props.placeholder || ''}
onChange={(e) => setProp((p: SearchBarProps) => { p.placeholder = e.target.value; })}
placeholder="Search..."
style={inputStyle}
/>
</div>
{/* Show Button */}
<div>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
checked={props.showButton !== false}
onChange={(e) => setProp((p: SearchBarProps) => { p.showButton = e.target.checked; })}
/>
Show Button
</label>
</div>
{/* Button Text */}
{props.showButton !== false && (
<div>
<label style={labelStyle}>Button Text</label>
<input
type="text"
value={props.buttonText || ''}
onChange={(e) => setProp((p: SearchBarProps) => { p.buttonText = e.target.value; })}
placeholder="Search"
style={inputStyle}
/>
</div>
)}
</div>
);
};
/* ---------- Craft config ---------- */
SearchBar.craft = {
displayName: 'Search Bar',
props: {
placeholder: 'Search...',
buttonText: 'Search',
showButton: true,
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: SearchBarSettings,
},
};
/* ---------- HTML export ---------- */
(SearchBar as any).toHtml = (props: SearchBarProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const {
placeholder = 'Search...',
buttonText = 'Search',
showButton = true,
style = {},
} = props;
const formStyle = cssPropsToString({
display: 'flex',
alignItems: 'center',
maxWidth: '560px',
...style,
});
const inputStyleStr = `width:100%;padding:12px 16px 12px 40px;font-size:15px;font-family:Inter,sans-serif;border:1px solid #d1d5db;border-radius:${showButton ? '8px 0 0 8px' : '8px'};background-color:#ffffff;color:#1f2937;outline:none;box-sizing:border-box`;
const btnHtml = showButton
? `<button type="submit" style="padding:12px 20px;font-size:15px;font-weight:600;font-family:Inter,sans-serif;color:#ffffff;background-color:#3b82f6;border:none;border-radius:0 8px 8px 0;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:6px"><i class="fa fa-search" style="font-size:13px"></i>${esc(buttonText)}</button>`
: '';
return {
html: `<form role="search"${formStyle ? ` style="${formStyle}"` : ''}>
<div style="position:relative;flex:1">
<i class="fa fa-search" style="position:absolute;left:14px;top:50%;transform:translateY(-50%);color:#9ca3af;font-size:14px;pointer-events:none"></i>
<input type="search" placeholder="${esc(placeholder)}" style="${inputStyleStr}" />
</div>
${btnHtml}
</form>`,
};
};

View File

@@ -0,0 +1,444 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface SocialLink {
platform: string;
url: string;
}
interface SocialLinksProps {
links?: SocialLink[];
iconSize?: string;
iconColor?: string;
iconBgColor?: string;
iconShape?: 'none' | 'circle' | 'square' | 'rounded';
gap?: string;
alignment?: 'left' | 'center' | 'right';
style?: CSSProperties;
}
const platformIcons: Record<string, string> = {
facebook: 'fa-facebook',
twitter: 'fa-twitter',
instagram: 'fa-instagram',
linkedin: 'fa-linkedin',
youtube: 'fa-youtube',
github: 'fa-github',
tiktok: 'fa-music',
pinterest: 'fa-pinterest',
snapchat: 'fa-snapchat',
whatsapp: 'fa-whatsapp',
};
const platformLabels: Record<string, string> = {
facebook: 'Facebook',
twitter: 'Twitter / X',
instagram: 'Instagram',
linkedin: 'LinkedIn',
youtube: 'YouTube',
github: 'GitHub',
tiktok: 'TikTok',
pinterest: 'Pinterest',
snapchat: 'Snapchat',
whatsapp: 'WhatsApp',
};
const allPlatforms = Object.keys(platformIcons);
const defaultLinks: SocialLink[] = [
{ platform: 'facebook', url: '#' },
{ platform: 'twitter', url: '#' },
{ platform: 'instagram', url: '#' },
{ platform: 'linkedin', url: '#' },
];
const getShapeStyle = (shape: string, size: string): CSSProperties => {
if (shape === 'none') return {};
const numSize = parseInt(size) || 24;
const boxSize = `${numSize + 16}px`;
const base: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: boxSize,
height: boxSize,
};
if (shape === 'circle') return { ...base, borderRadius: '50%' };
if (shape === 'square') return { ...base, borderRadius: '0' };
if (shape === 'rounded') return { ...base, borderRadius: '6px' };
return base;
};
const alignMap: Record<string, string> = {
left: 'flex-start',
center: 'center',
right: 'flex-end',
};
export const SocialLinks: UserComponent<SocialLinksProps> = ({
links = defaultLinks,
iconSize = '20px',
iconColor = '#ffffff',
iconBgColor = '#374151',
iconShape = 'circle',
gap = '10px',
alignment = 'center',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<div
ref={(ref: HTMLDivElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
display: 'flex',
flexWrap: 'wrap',
gap,
justifyContent: alignMap[alignment] || 'center',
alignItems: 'center',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
{links.map((link, i) => {
const iconClass = platformIcons[link.platform] || 'fa-link';
const shapeStyle = getShapeStyle(iconShape, iconSize);
const hasBg = iconShape !== 'none';
return (
<a
key={i}
href={link.url || '#'}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.preventDefault()}
title={platformLabels[link.platform] || link.platform}
style={{
textDecoration: 'none',
color: iconColor,
backgroundColor: hasBg ? iconBgColor : 'transparent',
transition: 'opacity 0.2s',
...shapeStyle,
}}
>
<i className={`fa ${iconClass}`} style={{ fontSize: iconSize }} />
</a>
);
})}
</div>
);
};
/* ---------- Settings panel ---------- */
const SocialLinksSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as SocialLinksProps,
}));
const links = props.links || defaultLinks;
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const updateLink = (index: number, field: keyof SocialLink, value: string) => {
setProp((p: SocialLinksProps) => {
const updated = [...(p.links || defaultLinks)];
updated[index] = { ...updated[index], [field]: value };
p.links = updated;
});
};
const addLink = (platform: string) => {
setProp((p: SocialLinksProps) => {
p.links = [...(p.links || defaultLinks), { platform, url: '#' }];
});
};
const removeLink = (index: number) => {
setProp((p: SocialLinksProps) => {
const updated = [...(p.links || defaultLinks)];
updated.splice(index, 1);
p.links = updated;
});
};
const usedPlatforms = new Set(links.map((l) => l.platform));
const availablePlatforms = allPlatforms.filter((p) => !usedPlatforms.has(p));
const sizePresets = ['14px', '18px', '20px', '24px', '28px', '32px'];
const gapPresets = ['4px', '8px', '10px', '14px', '20px'];
const iconColorPresets = ['#ffffff', '#18181b', '#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#f59e0b', '#a1a1aa'];
const bgColorPresets = ['#374151', '#18181b', '#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#0ea5e9', 'transparent'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Links Editor */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Social Links</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{links.map((link, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<span style={{ fontSize: 14, width: 20, textAlign: 'center', flex: 'none' }}>
<i className={`fa ${platformIcons[link.platform] || 'fa-link'}`} style={{ color: '#a1a1aa' }} />
</span>
<span style={{ fontSize: 11, color: '#e4e4e7', flex: 1 }}>
{platformLabels[link.platform] || link.platform}
</span>
<button
onClick={() => removeLink(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}
>
X
</button>
</div>
<input
type="text"
value={link.url}
onChange={(e) => updateLink(i, 'url', e.target.value)}
placeholder="https://..."
style={inputStyle}
/>
</div>
))}
</div>
{availablePlatforms.length > 0 && (
<div style={{ marginTop: 6 }}>
<select
onChange={(e) => {
if (e.target.value) {
addLink(e.target.value);
e.target.value = '';
}
}}
defaultValue=""
style={{ ...inputStyle, width: '100%', padding: '6px', cursor: 'pointer' }}
>
<option value="">+ Add Platform...</option>
{availablePlatforms.map((p) => (
<option key={p} value={p}>{platformLabels[p]}</option>
))}
</select>
</div>
)}
</div>
{/* Alignment */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Alignment</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['left', 'center', 'right'] as const).map((a) => (
<button
key={a}
onClick={() => setProp((p: SocialLinksProps) => { p.alignment = a; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.alignment === a ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
textTransform: 'capitalize',
}}
>
{a}
</button>
))}
</div>
</div>
{/* Icon Shape */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Icon Shape</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{(['none', 'circle', 'square', 'rounded'] as const).map((s) => (
<button
key={s}
onClick={() => setProp((p: SocialLinksProps) => { p.iconShape = s; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.iconShape === s ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
textTransform: 'capitalize',
}}
>
{s}
</button>
))}
</div>
</div>
{/* Icon Size */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Icon Size</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{sizePresets.map((s) => (
<button
key={s}
onClick={() => setProp((p: SocialLinksProps) => { p.iconSize = s; })}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.iconSize === s ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{s}
</button>
))}
</div>
</div>
{/* Icon Color */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Icon Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{iconColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: SocialLinksProps) => { p.iconColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.iconColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Icon Background Color */}
{props.iconShape !== 'none' && (
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Icon Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: SocialLinksProps) => { p.iconBgColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4,
border: c === 'transparent' ? '2px dashed #3f3f46' : '1px solid #3f3f46',
backgroundColor: c === 'transparent' ? undefined : c,
cursor: 'pointer',
outline: props.iconBgColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
)}
{/* Gap */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Gap</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{gapPresets.map((g) => (
<button
key={g}
onClick={() => setProp((p: SocialLinksProps) => { p.gap = g; })}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.gap === g ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{g}
</button>
))}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
SocialLinks.craft = {
displayName: 'Social Links',
props: {
links: defaultLinks,
iconSize: '20px',
iconColor: '#ffffff',
iconBgColor: '#374151',
iconShape: 'circle',
gap: '10px',
alignment: 'center',
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: SocialLinksSettings,
},
};
/* ---------- HTML export ---------- */
(SocialLinks as any).toHtml = (props: SocialLinksProps, _childrenHtml: string) => {
const links = props.links || defaultLinks;
const iconSize = props.iconSize || '20px';
const iconColor = props.iconColor || '#ffffff';
const iconBgColor = props.iconBgColor || '#374151';
const iconShape = props.iconShape || 'circle';
const gap = props.gap || '10px';
const alignment = props.alignment || 'center';
const hasBg = iconShape !== 'none';
const wrapperStyle = cssPropsToString({
display: 'flex',
flexWrap: 'wrap',
gap,
justifyContent: alignMap[alignment] || 'center',
alignItems: 'center',
...props.style,
});
const numSize = parseInt(iconSize) || 20;
const boxSize = `${numSize + 16}px`;
const getShapeStr = (): string => {
const parts: string[] = [
`display:inline-flex`,
`align-items:center`,
`justify-content:center`,
`width:${boxSize}`,
`height:${boxSize}`,
];
if (iconShape === 'circle') parts.push('border-radius:50%');
else if (iconShape === 'square') parts.push('border-radius:0');
else if (iconShape === 'rounded') parts.push('border-radius:6px');
return parts.join(';');
};
const linksHtml = links.map((link) => {
const iconClass = platformIcons[link.platform] || 'fa-link';
const title = platformLabels[link.platform] || link.platform;
let aStyle = `text-decoration:none;color:${iconColor};background-color:${hasBg ? iconBgColor : 'transparent'}`;
if (hasBg) {
aStyle += `;${getShapeStr()}`;
}
return `<a href="${link.url || '#'}" target="_blank" rel="noopener noreferrer" title="${title}" style="${aStyle}"><i class="fa ${iconClass}" style="font-size:${iconSize}"></i></a>`;
}).join('\n ');
return {
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>
${linksHtml}
</div>`,
};
};

View File

@@ -0,0 +1,107 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface SpacerProps {
height?: string;
style?: CSSProperties;
}
export const Spacer: UserComponent<SpacerProps> = ({
height = '40px',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<div
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
height,
outline: selected ? '2px dashed #3b82f6' : 'none',
...style,
...(selected && !style.backgroundColor && !style.background
? { background: 'rgba(59,130,246,0.05)' }
: {}),
}}
/>
);
};
/* ---------- Settings panel ---------- */
const SpacerSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as SpacerProps,
}));
const heightPresets = ['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 }}>Height</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{heightPresets.map((h) => (
<button
key={h}
onClick={() => setProp((p: SpacerProps) => { p.height = h; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.height === h ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{h}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Custom Height</label>
<input
type="text"
value={props.height || ''}
onChange={(e) => setProp((p: SpacerProps) => { p.height = e.target.value; })}
placeholder="e.g. 50px, 5rem"
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11 }}
/>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Spacer.craft = {
displayName: 'Spacer',
props: {
height: '40px',
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: SpacerSettings,
},
};
/* ---------- HTML export ---------- */
(Spacer as any).toHtml = (props: SpacerProps, _childrenHtml: string) => {
const styleStr = cssPropsToString({
height: props.height || '40px',
...props.style,
});
return { html: `<div${styleStr ? ` style="${styleStr}"` : ''}></div>` };
};

View File

@@ -0,0 +1,230 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface StarRatingProps {
rating?: number;
maxStars?: number;
size?: string;
filledColor?: string;
emptyColor?: string;
style?: CSSProperties;
}
export const StarRating: UserComponent<StarRatingProps> = ({
rating = 4.5,
maxStars = 5,
size = '24px',
filledColor = '#f59e0b',
emptyColor = '#d1d5db',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const stars: React.ReactNode[] = [];
for (let i = 1; i <= maxStars; i++) {
if (i <= Math.floor(rating)) {
// Full star
stars.push(
<i
key={i}
className="fa fa-star"
style={{ color: filledColor, fontSize: size }}
/>
);
} else if (i === Math.ceil(rating) && rating % 1 !== 0) {
// Half star
stars.push(
<span key={i} style={{ position: 'relative', display: 'inline-block', fontSize: size }}>
<i className="fa fa-star" style={{ color: emptyColor }} />
<span style={{ position: 'absolute', left: 0, top: 0, overflow: 'hidden', width: '50%' }}>
<i className="fa fa-star" style={{ color: filledColor }} />
</span>
</span>
);
} else {
// Empty star
stars.push(
<i
key={i}
className="fa fa-star"
style={{ color: emptyColor, fontSize: size }}
/>
);
}
}
return (
<span
ref={(ref: HTMLSpanElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '2px',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
{stars}
</span>
);
};
/* ---------- Settings panel ---------- */
const StarRatingSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as StarRatingProps,
}));
const sizePresets = ['16px', '20px', '24px', '32px', '40px'];
const filledColorPresets = ['#f59e0b', '#eab308', '#f97316', '#ef4444', '#ec4899', '#3b82f6', '#10b981', '#18181b'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>
Rating: {props.rating ?? 4.5}
</label>
<input
type="range"
min={0}
max={props.maxStars || 5}
step={0.5}
value={props.rating ?? 4.5}
onChange={(e) => setProp((p: StarRatingProps) => { p.rating = parseFloat(e.target.value); })}
style={{ width: '100%', accentColor: '#3b82f6' }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Max Stars</label>
<div style={{ display: 'flex', gap: 4 }}>
{[3, 4, 5, 6, 7, 10].map((n) => (
<button
key={n}
onClick={() => setProp((p: StarRatingProps) => {
p.maxStars = n;
if ((p.rating || 0) > n) p.rating = n;
})}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.maxStars === n ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{n}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Star Size</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{sizePresets.map((s) => (
<button
key={s}
onClick={() => setProp((p: StarRatingProps) => { p.size = s; })}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.size === s ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{s}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Filled Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{filledColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: StarRatingProps) => { p.filledColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.filledColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Empty Color</label>
<input
type="color"
value={props.emptyColor || '#d1d5db'}
onChange={(e) => setProp((p: StarRatingProps) => { p.emptyColor = e.target.value; })}
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
/>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
StarRating.craft = {
displayName: 'Star Rating',
props: {
rating: 4.5,
maxStars: 5,
size: '24px',
filledColor: '#f59e0b',
emptyColor: '#d1d5db',
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: StarRatingSettings,
},
};
/* ---------- HTML export ---------- */
(StarRating as any).toHtml = (props: StarRatingProps, _childrenHtml: string) => {
const rating = props.rating ?? 4.5;
const maxStars = props.maxStars || 5;
const size = props.size || '24px';
const filledColor = props.filledColor || '#f59e0b';
const emptyColor = props.emptyColor || '#d1d5db';
const wrapperStyle = cssPropsToString({
display: 'inline-flex',
alignItems: 'center',
gap: '2px',
...props.style,
});
let starsHtml = '';
for (let i = 1; i <= maxStars; i++) {
if (i <= Math.floor(rating)) {
starsHtml += `<i class="fa fa-star" style="color:${filledColor};font-size:${size}"></i>`;
} else if (i === Math.ceil(rating) && rating % 1 !== 0) {
starsHtml += `<span style="position:relative;display:inline-block;font-size:${size}"><i class="fa fa-star" style="color:${emptyColor}"></i><span style="position:absolute;left:0;top:0;overflow:hidden;width:50%"><i class="fa fa-star" style="color:${filledColor}"></i></span></span>`;
} else {
starsHtml += `<i class="fa fa-star" style="color:${emptyColor};font-size:${size}"></i>`;
}
}
return {
html: `<span${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>${starsHtml}</span>`,
};
};

View File

@@ -0,0 +1,158 @@
import React, { CSSProperties, useCallback, useRef, useEffect } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { SettingsTabs } from '../../ui/SettingsTabs';
import { TypographyControl } from '../../ui/TypographyControl';
import { AdvancedTab } from '../../ui/AdvancedTab';
interface TextBlockProps {
text?: string;
style?: CSSProperties;
cssId?: string;
cssClass?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
animation?: string;
animationDelay?: string;
}
export const TextBlock: UserComponent<TextBlockProps> = ({
text = 'Start typing here...',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
actions: { setProp },
} = useNode((node) => ({
selected: node.events.selected,
}));
const elRef = useRef<HTMLParagraphElement | null>(null);
const editedTextRef = useRef<string | null>(null);
const commitText = useCallback(() => {
if (elRef.current) {
const newText = elRef.current.innerText;
editedTextRef.current = newText;
setProp((p: TextBlockProps) => { p.text = newText; });
}
}, [setProp]);
const handleBlur = useCallback(() => { commitText(); }, [commitText]);
useEffect(() => {
if (!selected && editedTextRef.current !== null) {
setProp((p: TextBlockProps) => { p.text = editedTextRef.current!; });
editedTextRef.current = null;
}
}, [selected, setProp]);
useEffect(() => {
if (elRef.current && !selected && editedTextRef.current === null) {
elRef.current.innerText = text || '';
}
}, [text, selected]);
return (
<p
ref={(ref: HTMLParagraphElement | null) => {
elRef.current = ref;
if (ref) connect(drag(ref));
}}
contentEditable={selected}
suppressContentEditableWarning
onBlur={handleBlur}
onInput={() => { if (elRef.current) editedTextRef.current = elRef.current.innerText; }}
style={{
outline: 'none',
cursor: selected ? 'text' : 'pointer',
minHeight: '1em',
...style,
}}
/>
);
};
/* ---------- Settings panel ---------- */
const TextBlockSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as TextBlockProps,
}));
return (
<SettingsTabs
general={
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={{ fontSize: 11, fontWeight: 600, color: '#a1a1aa', display: 'block', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.3px' }}>Text Content</label>
<textarea
value={props.text || ''}
onChange={(e) => setProp((p: TextBlockProps) => { p.text = e.target.value; })}
rows={4}
style={{ width: '100%', padding: '6px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12, resize: 'vertical' }}
/>
</div>
</div>
}
style={
<TypographyControl
style={props.style || {}}
onChange={(updates) => setProp((p: TextBlockProps) => { p.style = { ...p.style, ...updates }; })}
/>
}
advanced={
<AdvancedTab
style={props.style || {}}
onStyleChange={(updates) => setProp((p: TextBlockProps) => { p.style = { ...p.style, ...updates }; })}
cssId={props.cssId || ''}
onCssIdChange={(id) => setProp((p: TextBlockProps) => { p.cssId = id; })}
cssClass={props.cssClass || ''}
onCssClassChange={(cls) => setProp((p: TextBlockProps) => { p.cssClass = cls; })}
hideOnDesktop={props.hideOnDesktop}
onHideOnDesktopChange={(v) => setProp((p: TextBlockProps) => { p.hideOnDesktop = v; })}
hideOnTablet={props.hideOnTablet}
onHideOnTabletChange={(v) => setProp((p: TextBlockProps) => { p.hideOnTablet = v; })}
hideOnMobile={props.hideOnMobile}
onHideOnMobileChange={(v) => setProp((p: TextBlockProps) => { p.hideOnMobile = v; })}
animation={props.animation}
onAnimationChange={(v) => setProp((p: TextBlockProps) => { p.animation = v; })}
animationDelay={props.animationDelay}
onAnimationDelayChange={(v) => setProp((p: TextBlockProps) => { p.animationDelay = v; })}
/>
}
/>
);
};
/* ---------- Craft config ---------- */
TextBlock.craft = {
displayName: 'Text',
props: {
text: 'Start typing here...',
style: {
fontSize: '16px',
lineHeight: '1.6',
color: '#3f3f46',
},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: TextBlockSettings,
},
};
/* ---------- HTML export ---------- */
(TextBlock as any).toHtml = (props: TextBlockProps, _childrenHtml: string) => {
const styleStr = cssPropsToString(props.style);
const escapedText = (props.text || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return { html: `<p${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</p>` };
};

View File

@@ -0,0 +1,423 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface ContactFormField {
type: 'text' | 'email' | 'tel' | 'textarea' | 'select';
label: string;
name: string;
placeholder: string;
required: boolean;
options?: string[];
}
interface ContactFormProps {
fields?: ContactFormField[];
submitText?: string;
submitColor?: string;
formAction?: string;
successMessage?: string;
style?: CSSProperties;
labelColor?: string;
inputBg?: string;
inputBorder?: string;
}
const defaultFields: ContactFormField[] = [
{ type: 'text', label: 'Name', name: 'name', placeholder: 'Your name', required: true },
{ type: 'email', label: 'Email', name: 'email', placeholder: 'your@email.com', required: true },
{ type: 'tel', label: 'Phone', name: 'phone', placeholder: '(555) 123-4567', required: false },
{ type: 'textarea', label: 'Message', name: 'message', placeholder: 'How can we help you?', required: true },
];
export const ContactForm: UserComponent<ContactFormProps> = ({
fields = defaultFields,
submitText = 'Send Message',
submitColor = '#3b82f6',
formAction = '#',
successMessage = 'Thank you! We\'ll get back to you soon.',
style = {},
labelColor = '#374151',
inputBg = '#ffffff',
inputBorder = '#d1d5db',
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const inputBaseStyle: CSSProperties = {
width: '100%',
padding: '10px 14px',
fontSize: '14px',
fontFamily: 'Inter, sans-serif',
border: `1px solid ${inputBorder}`,
borderRadius: '6px',
backgroundColor: inputBg,
color: '#1f2937',
boxSizing: 'border-box',
outline: 'none',
};
return (
<form
ref={(ref: HTMLFormElement | null): void => { if (ref) connect(drag(ref)); }}
action={formAction}
method="POST"
onSubmit={(e) => e.preventDefault()}
style={{
padding: '32px',
display: 'flex',
flexDirection: 'column',
gap: '20px',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
{fields.map((field, i) => (
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
<label style={{ fontSize: '14px', fontWeight: '500', color: labelColor }}>
{field.label}
{field.required && <span style={{ color: '#ef4444', marginLeft: '2px' }}>*</span>}
</label>
{field.type === 'textarea' ? (
<textarea
name={field.name}
placeholder={field.placeholder}
required={field.required}
rows={4}
style={{ ...inputBaseStyle, resize: 'vertical' }}
/>
) : field.type === 'select' ? (
<select
name={field.name}
required={field.required}
style={{ ...inputBaseStyle, cursor: 'pointer' }}
>
<option value="">{field.placeholder || 'Select...'}</option>
{(field.options || []).map((opt, j) => (
<option key={j} value={opt}>{opt}</option>
))}
</select>
) : (
<input
type={field.type}
name={field.name}
placeholder={field.placeholder}
required={field.required}
style={inputBaseStyle}
/>
)}
</div>
))}
<button
type="submit"
style={{
padding: '12px 32px',
fontSize: '16px',
fontWeight: '600',
fontFamily: 'Inter, sans-serif',
color: '#ffffff',
backgroundColor: submitColor,
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
alignSelf: 'flex-start',
}}
>
{submitText}
</button>
</form>
);
};
/* ---------- Settings panel ---------- */
const fieldTypes: ContactFormField['type'][] = ['text', 'email', 'tel', 'textarea', 'select'];
const ContactFormSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as ContactFormProps,
}));
const fields = props.fields || defaultFields;
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const updateField = (index: number, key: keyof ContactFormField, value: any) => {
setProp((p: ContactFormProps) => {
const updated = [...(p.fields || defaultFields)];
updated[index] = { ...updated[index], [key]: value };
p.fields = updated;
});
};
const addField = () => {
setProp((p: ContactFormProps) => {
p.fields = [...(p.fields || defaultFields), { type: 'text', label: 'New Field', name: 'new_field', placeholder: '', required: false }];
});
};
const removeField = (index: number) => {
setProp((p: ContactFormProps) => {
const updated = [...(p.fields || defaultFields)];
updated.splice(index, 1);
p.fields = updated;
});
};
const submitColorPresets = ['#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#f59e0b', '#18181b', '#0ea5e9', '#ec4899'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Form Action */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Form Action URL</label>
<input
type="text"
value={props.formAction || ''}
onChange={(e) => setProp((p: ContactFormProps) => { p.formAction = e.target.value; })}
placeholder="https://... or /api/submit"
style={{ ...inputStyle, padding: '4px 8px', fontSize: 12 }}
/>
</div>
{/* Success Message */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Success Message</label>
<input
type="text"
value={props.successMessage || ''}
onChange={(e) => setProp((p: ContactFormProps) => { p.successMessage = e.target.value; })}
style={{ ...inputStyle, padding: '4px 8px', fontSize: 12 }}
/>
</div>
{/* Submit Button */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Submit Button Text</label>
<input
type="text"
value={props.submitText || ''}
onChange={(e) => setProp((p: ContactFormProps) => { p.submitText = e.target.value; })}
style={{ ...inputStyle, padding: '4px 8px', fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Submit Button Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{submitColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: ContactFormProps) => { p.submitColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.submitColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Label Color */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Label Color</label>
<input
type="color"
value={props.labelColor || '#374151'}
onChange={(e) => setProp((p: ContactFormProps) => { p.labelColor = e.target.value; })}
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
/>
</div>
{/* Input Background */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Input Background</label>
<input
type="color"
value={props.inputBg || '#ffffff'}
onChange={(e) => setProp((p: ContactFormProps) => { p.inputBg = e.target.value; })}
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
/>
</div>
{/* Input Border */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Input Border Color</label>
<input
type="color"
value={props.inputBorder || '#d1d5db'}
onChange={(e) => setProp((p: ContactFormProps) => { p.inputBorder = e.target.value; })}
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
/>
</div>
{/* Fields Editor */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Fields</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{fields.map((field, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<select
value={field.type}
onChange={(e) => updateField(i, 'type', e.target.value)}
style={{ ...inputStyle, width: 70, flex: 'none', cursor: 'pointer' }}
>
{fieldTypes.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<input
type="text"
value={field.label}
onChange={(e) => updateField(i, 'label', e.target.value)}
placeholder="Label"
style={{ ...inputStyle, flex: 1 }}
/>
<button
onClick={() => removeField(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}
>
X
</button>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<input
type="text"
value={field.name}
onChange={(e) => updateField(i, 'name', e.target.value)}
placeholder="name attr"
style={{ ...inputStyle, flex: 1 }}
/>
<input
type="text"
value={field.placeholder}
onChange={(e) => updateField(i, 'placeholder', e.target.value)}
placeholder="Placeholder"
style={{ ...inputStyle, flex: 1 }}
/>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<input
type="checkbox"
checked={field.required}
onChange={(e) => updateField(i, 'required', e.target.checked)}
/>
Required
</label>
</div>
{field.type === 'select' && (
<div>
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'block', marginBottom: 2 }}>Options (one per line)</label>
<textarea
value={(field.options || []).join('\n')}
onChange={(e) => updateField(i, 'options', e.target.value.split('\n').filter((s: string) => s.trim()))}
rows={3}
placeholder="Option 1&#10;Option 2&#10;Option 3"
style={{ ...inputStyle, resize: 'vertical' }}
/>
</div>
)}
</div>
))}
</div>
<button
onClick={addField}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Field
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
ContactForm.craft = {
displayName: 'Contact Form',
props: {
fields: defaultFields,
submitText: 'Send Message',
submitColor: '#3b82f6',
formAction: '#',
successMessage: 'Thank you! We\'ll get back to you soon.',
style: {
backgroundColor: '#ffffff',
borderRadius: '12px',
border: '1px solid #e5e7eb',
},
labelColor: '#374151',
inputBg: '#ffffff',
inputBorder: '#d1d5db',
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: ContactFormSettings,
},
};
/* ---------- HTML export ---------- */
(ContactForm as any).toHtml = (props: ContactFormProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const formStyle = cssPropsToString({
padding: '32px',
display: 'flex',
flexDirection: 'column',
gap: '20px',
...props.style,
});
const labelColor = props.labelColor || '#374151';
const inputBg = props.inputBg || '#ffffff';
const inputBorder = props.inputBorder || '#d1d5db';
const inputStyleStr = `width:100%;padding:10px 14px;font-size:14px;font-family:Inter,sans-serif;border:1px solid ${inputBorder};border-radius:6px;background-color:${inputBg};color:#1f2937;box-sizing:border-box;outline:none`;
const fieldsHtml = (props.fields || defaultFields).map((field) => {
const reqStar = field.required ? '<span style="color:#ef4444;margin-left:2px">*</span>' : '';
const labelHtml = `<label style="font-size:14px;font-weight:500;color:${labelColor}">${esc(field.label)}${reqStar}</label>`;
const reqAttr = field.required ? ' required' : '';
let inputHtml = '';
if (field.type === 'textarea') {
inputHtml = `<textarea name="${esc(field.name)}" placeholder="${esc(field.placeholder)}" rows="4" style="${inputStyleStr};resize:vertical"${reqAttr}></textarea>`;
} else if (field.type === 'select') {
const opts = (field.options || []).map((o) => `<option value="${esc(o)}">${esc(o)}</option>`).join('');
inputHtml = `<select name="${esc(field.name)}" style="${inputStyleStr};cursor:pointer"${reqAttr}><option value="">${esc(field.placeholder || 'Select...')}</option>${opts}</select>`;
} else {
inputHtml = `<input type="${field.type}" name="${esc(field.name)}" placeholder="${esc(field.placeholder)}" style="${inputStyleStr}"${reqAttr} />`;
}
return `<div style="display:flex;flex-direction:column;gap:6px">${labelHtml}${inputHtml}</div>`;
}).join('\n ');
const btnStyle = cssPropsToString({
padding: '12px 32px',
fontSize: '16px',
fontWeight: '600',
fontFamily: 'Inter, sans-serif',
color: '#ffffff',
backgroundColor: props.submitColor || '#3b82f6',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
alignSelf: 'flex-start',
});
return {
html: `<form action="${esc(props.formAction || '#')}" method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
${fieldsHtml}
<button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(props.submitText || 'Send Message')}</button>
</form>`,
};
};

View File

@@ -0,0 +1,179 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface FormButtonProps {
text?: string;
style?: CSSProperties;
}
export const FormButton: UserComponent<FormButtonProps> = ({
text = 'Submit',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<button
ref={(ref: HTMLButtonElement | null): void => { if (ref) connect(drag(ref)); }}
type="submit"
onClick={(e) => e.preventDefault()}
style={{
padding: '12px 32px',
backgroundColor: '#3b82f6',
color: '#ffffff',
border: 'none',
borderRadius: '6px',
fontSize: '16px',
fontWeight: '600',
cursor: 'pointer',
outline: selected ? '2px solid #3b82f6' : 'none',
outlineOffset: selected ? '2px' : '0',
...style,
}}
>
{text}
</button>
);
};
/* ---------- Settings panel ---------- */
const FormButtonSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as FormButtonProps,
}));
const colorPresets = [
{ bg: '#3b82f6', color: '#ffffff', label: 'Blue' },
{ bg: '#10b981', color: '#ffffff', label: 'Green' },
{ bg: '#ef4444', color: '#ffffff', label: 'Red' },
{ bg: '#f59e0b', color: '#18181b', label: 'Amber' },
{ bg: '#8b5cf6', color: '#ffffff', label: 'Purple' },
{ bg: '#18181b', color: '#ffffff', label: 'Dark' },
];
const radiusPresets = ['0px', '4px', '6px', '8px', '9999px'];
const widthPresets = ['auto', '100%'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Text</label>
<input
type="text"
value={props.text || ''}
onChange={(e) => setProp((p: FormButtonProps) => { p.text = e.target.value; })}
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 }}>Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorPresets.map((preset) => (
<button
key={preset.label}
onClick={() => setProp((p: FormButtonProps) => {
p.style = { ...p.style, backgroundColor: preset.bg, color: preset.color };
})}
title={preset.label}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: preset.bg, cursor: 'pointer',
outline: props.style?.backgroundColor === preset.bg ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Border Radius</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{radiusPresets.map((r) => (
<button
key={r}
onClick={() => setProp((p: FormButtonProps) => { p.style = { ...p.style, borderRadius: r }; })}
style={{
padding: '2px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.style?.borderRadius === r ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{r}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Width</label>
<div style={{ display: 'flex', gap: 4 }}>
{widthPresets.map((w) => (
<button
key={w}
onClick={() => setProp((p: FormButtonProps) => { p.style = { ...p.style, width: w }; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.style?.width === w ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{w === 'auto' ? 'Auto' : 'Full Width'}
</button>
))}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
FormButton.craft = {
displayName: 'Submit Button',
props: {
text: 'Submit',
style: {
backgroundColor: '#3b82f6',
color: '#ffffff',
padding: '12px 32px',
borderRadius: '6px',
fontWeight: '600',
fontSize: '16px',
border: 'none',
},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: FormButtonSettings,
},
};
/* ---------- HTML export ---------- */
(FormButton as any).toHtml = (props: FormButtonProps, _childrenHtml: string) => {
const styleStr = cssPropsToString({
padding: '12px 32px',
border: 'none',
cursor: 'pointer',
...props.style,
});
const escapedText = (props.text || 'Submit').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return {
html: `<button type="submit"${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</button>`,
};
};

View File

@@ -0,0 +1,140 @@
import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from '../layout/Container';
import { cssPropsToString } from '../../utils/style-helpers';
interface FormContainerProps {
action?: string;
method?: 'GET' | 'POST';
style?: CSSProperties;
children?: React.ReactNode;
}
export const FormContainer: UserComponent<FormContainerProps> = ({
action = '#',
method = 'POST',
style = {},
}) => {
const { connectors: { connect, drag } } = useNode();
return (
<form
ref={(ref: HTMLFormElement | null): void => { if (ref) connect(drag(ref)); }}
action={action}
method={method}
onSubmit={(e) => e.preventDefault()}
style={{
padding: '24px',
minHeight: '80px',
...style,
}}
>
<Element
id="form-inner"
is={Container}
canvas
style={{ display: 'flex', flexDirection: 'column', gap: '16px', padding: '0' }}
tag="div"
/>
</form>
);
};
/* ---------- Settings panel ---------- */
const FormContainerSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as FormContainerProps,
}));
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Form Action URL</label>
<input
type="text"
value={props.action || ''}
onChange={(e) => setProp((p: FormContainerProps) => { p.action = e.target.value; })}
placeholder="https://... or /api/submit"
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 }}>Method</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['GET', 'POST'] as const).map((m) => (
<button
key={m}
onClick={() => setProp((p: FormContainerProps) => { p.method = m; })}
style={{
padding: '4px 12px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.method === m ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{m}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: FormContainerProps) => { 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>
);
};
/* ---------- Craft config ---------- */
FormContainer.craft = {
displayName: 'Form',
props: {
action: '#',
method: 'POST',
style: {
padding: '24px',
backgroundColor: '#ffffff',
borderRadius: '8px',
border: '1px solid #e4e4e7',
},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: FormContainerSettings,
},
};
/* ---------- HTML export ---------- */
(FormContainer as any).toHtml = (props: FormContainerProps, childrenHtml: string) => {
const styleStr = cssPropsToString({
padding: '24px',
...props.style,
});
return {
html: `<form action="${props.action || '#'}" method="${props.method || 'POST'}"${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</form>`,
};
};

View File

@@ -0,0 +1,185 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface InputFieldProps {
label?: string;
type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url';
name?: string;
placeholder?: string;
required?: boolean;
style?: CSSProperties;
}
export const InputField: UserComponent<InputFieldProps> = ({
label = 'Label',
type = 'text',
name = 'field',
placeholder = '',
required = false,
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<div
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
display: 'flex',
flexDirection: 'column',
gap: '4px',
outline: selected ? '2px solid #3b82f6' : 'none',
borderRadius: '4px',
...style,
}}
>
{label && (
<label style={{ fontSize: '14px', fontWeight: '500', color: '#18181b' }}>
{label}{required && <span style={{ color: '#ef4444' }}> *</span>}
</label>
)}
<input
type={type}
name={name}
placeholder={placeholder}
required={required}
style={{
padding: '10px 12px',
border: '1px solid #d4d4d8',
borderRadius: '6px',
fontSize: '14px',
color: '#18181b',
backgroundColor: '#ffffff',
outline: 'none',
width: '100%',
boxSizing: 'border-box',
}}
readOnly
/>
</div>
);
};
/* ---------- Settings panel ---------- */
const InputFieldSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as InputFieldProps,
}));
const typeOptions: InputFieldProps['type'][] = ['text', 'email', 'password', 'number', 'tel', 'url'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Label</label>
<input
type="text"
value={props.label || ''}
onChange={(e) => setProp((p: InputFieldProps) => { p.label = e.target.value; })}
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 }}>Type</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{typeOptions.map((t) => (
<button
key={t}
onClick={() => setProp((p: InputFieldProps) => { p.type = t; })}
style={{
padding: '3px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.type === t ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{t}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Name</label>
<input
type="text"
value={props.name || ''}
onChange={(e) => setProp((p: InputFieldProps) => { p.name = e.target.value; })}
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 }}>Placeholder</label>
<input
type="text"
value={props.placeholder || ''}
onChange={(e) => setProp((p: InputFieldProps) => { p.placeholder = e.target.value; })}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
checked={!!props.required}
onChange={(e) => setProp((p: InputFieldProps) => { p.required = e.target.checked; })}
/>
Required
</label>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
InputField.craft = {
displayName: 'Input',
props: {
label: 'Your Name',
type: 'text',
name: 'name',
placeholder: 'Enter your name',
required: false,
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: InputFieldSettings,
},
};
/* ---------- HTML export ---------- */
(InputField as any).toHtml = (props: InputFieldProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const wrapStyle = cssPropsToString({
display: 'flex',
flexDirection: 'column',
gap: '4px',
...props.style,
});
const reqAttr = props.required ? ' required' : '';
const labelHtml = props.label
? `<label style="font-size:14px;font-weight:500;color:#18181b">${esc(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>`
: '';
return {
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
${labelHtml}
<input type="${props.type || 'text'}" name="${esc(props.name || 'field')}" placeholder="${esc(props.placeholder || '')}"${reqAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box" />
</div>`,
};
};

View File

@@ -0,0 +1,307 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface SubscribeFormProps {
heading?: string;
placeholder?: string;
buttonText?: string;
buttonColor?: string;
layout?: 'inline' | 'stacked';
style?: CSSProperties;
}
export const SubscribeForm: UserComponent<SubscribeFormProps> = ({
heading = 'Subscribe to our newsletter',
placeholder = 'Enter your email',
buttonText = 'Subscribe',
buttonColor = '#3b82f6',
layout = 'inline',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const isInline = layout === 'inline';
return (
<div
ref={(ref: HTMLDivElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
padding: '40px 24px',
textAlign: 'center',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
{heading && (
<h3 style={{
fontSize: '22px',
fontWeight: '600',
color: '#1f2937',
marginBottom: '20px',
fontFamily: 'Inter, sans-serif',
}}>
{heading}
</h3>
)}
<form
onSubmit={(e) => e.preventDefault()}
style={{
display: 'flex',
flexDirection: isInline ? 'row' : 'column',
gap: isInline ? '0' : '12px',
maxWidth: isInline ? '480px' : '360px',
margin: '0 auto',
alignItems: 'stretch',
}}
>
<input
type="email"
placeholder={placeholder}
style={{
flex: 1,
padding: '12px 16px',
fontSize: '15px',
fontFamily: 'Inter, sans-serif',
border: '1px solid #d1d5db',
borderRadius: isInline ? '8px 0 0 8px' : '8px',
backgroundColor: '#ffffff',
color: '#1f2937',
outline: 'none',
boxSizing: 'border-box',
}}
/>
<button
type="submit"
style={{
padding: '12px 24px',
fontSize: '15px',
fontWeight: '600',
fontFamily: 'Inter, sans-serif',
color: '#ffffff',
backgroundColor: buttonColor,
border: 'none',
borderRadius: isInline ? '0 8px 8px 0' : '8px',
cursor: 'pointer',
whiteSpace: 'nowrap',
}}
>
{buttonText}
</button>
</form>
</div>
);
};
/* ---------- Settings panel ---------- */
const SubscribeFormSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as SubscribeFormProps,
}));
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
const inputStyle: CSSProperties = {
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
};
const buttonColorPresets = ['#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#f59e0b', '#18181b', '#0ea5e9', '#ec4899'];
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a', '#eff6ff', '#f0fdf4', '#fef3c7'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Heading */}
<div>
<label style={labelStyle}>Heading</label>
<input
type="text"
value={props.heading || ''}
onChange={(e) => setProp((p: SubscribeFormProps) => { p.heading = e.target.value; })}
placeholder="Subscribe to our newsletter"
style={inputStyle}
/>
</div>
{/* Placeholder */}
<div>
<label style={labelStyle}>Placeholder</label>
<input
type="text"
value={props.placeholder || ''}
onChange={(e) => setProp((p: SubscribeFormProps) => { p.placeholder = e.target.value; })}
placeholder="Enter your email"
style={inputStyle}
/>
</div>
{/* Button Text */}
<div>
<label style={labelStyle}>Button Text</label>
<input
type="text"
value={props.buttonText || ''}
onChange={(e) => setProp((p: SubscribeFormProps) => { p.buttonText = e.target.value; })}
placeholder="Subscribe"
style={inputStyle}
/>
</div>
{/* Layout */}
<div>
<label style={labelStyle}>Layout</label>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => setProp((p: SubscribeFormProps) => { p.layout = 'inline'; })}
style={{
flex: 1, padding: '6px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: (props.layout || 'inline') === 'inline' ? '#3b82f6' : '#27272a',
color: (props.layout || 'inline') === 'inline' ? '#fff' : '#a1a1aa',
fontWeight: 500,
}}
>
Inline
</button>
<button
onClick={() => setProp((p: SubscribeFormProps) => { p.layout = 'stacked'; })}
style={{
flex: 1, padding: '6px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.layout === 'stacked' ? '#3b82f6' : '#27272a',
color: props.layout === 'stacked' ? '#fff' : '#a1a1aa',
fontWeight: 500,
}}
>
Stacked
</button>
</div>
</div>
{/* Button Color */}
<div>
<label style={labelStyle}>Button Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{buttonColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: SubscribeFormProps) => { p.buttonColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: (props.buttonColor || '#3b82f6') === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Background */}
<div>
<label style={labelStyle}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: SubscribeFormProps) => { 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>
);
};
/* ---------- Craft config ---------- */
SubscribeForm.craft = {
displayName: 'Subscribe Form',
props: {
heading: 'Subscribe to our newsletter',
placeholder: 'Enter your email',
buttonText: 'Subscribe',
buttonColor: '#3b82f6',
layout: 'inline',
style: { backgroundColor: '#f8fafc' },
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: SubscribeFormSettings,
},
};
/* ---------- HTML export ---------- */
(SubscribeForm as any).toHtml = (props: SubscribeFormProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const {
heading = 'Subscribe to our newsletter',
placeholder = 'Enter your email',
buttonText = 'Subscribe',
buttonColor = '#3b82f6',
layout = 'inline',
style = {},
} = props;
const isInline = layout === 'inline';
const wrapperStyle = cssPropsToString({
padding: '40px 24px',
textAlign: 'center',
...style,
});
const headingHtml = heading
? `<h3 style="font-size:22px;font-weight:600;color:#1f2937;margin-bottom:20px;font-family:Inter,sans-serif">${esc(heading)}</h3>`
: '';
const formStyle = cssPropsToString({
display: 'flex',
flexDirection: isInline ? 'row' : 'column',
gap: isInline ? '0' : '12px',
maxWidth: isInline ? '480px' : '360px',
margin: '0 auto',
alignItems: 'stretch',
});
const inputStyleStr = `flex:1;padding:12px 16px;font-size:15px;font-family:Inter,sans-serif;border:1px solid #d1d5db;border-radius:${isInline ? '8px 0 0 8px' : '8px'};background-color:#ffffff;color:#1f2937;outline:none;box-sizing:border-box`;
const btnStyle = cssPropsToString({
padding: '12px 24px',
fontSize: '15px',
fontWeight: '600',
fontFamily: 'Inter, sans-serif',
color: '#ffffff',
backgroundColor: buttonColor,
border: 'none',
borderRadius: isInline ? '0 8px 8px 0' : '8px',
cursor: 'pointer',
whiteSpace: 'nowrap',
});
return {
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}>
${headingHtml}
<form method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
<input type="email" name="email" placeholder="${esc(placeholder)}" required style="${inputStyleStr}" />
<button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(buttonText)}</button>
</form>
</div>`,
};
};

View File

@@ -0,0 +1,187 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface TextareaFieldProps {
label?: string;
name?: string;
placeholder?: string;
rows?: number;
required?: boolean;
style?: CSSProperties;
}
export const TextareaField: UserComponent<TextareaFieldProps> = ({
label = 'Message',
name = 'message',
placeholder = '',
rows = 4,
required = false,
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<div
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
display: 'flex',
flexDirection: 'column',
gap: '4px',
outline: selected ? '2px solid #3b82f6' : 'none',
borderRadius: '4px',
...style,
}}
>
{label && (
<label style={{ fontSize: '14px', fontWeight: '500', color: '#18181b' }}>
{label}{required && <span style={{ color: '#ef4444' }}> *</span>}
</label>
)}
<textarea
name={name}
placeholder={placeholder}
rows={rows}
required={required}
readOnly
style={{
padding: '10px 12px',
border: '1px solid #d4d4d8',
borderRadius: '6px',
fontSize: '14px',
color: '#18181b',
backgroundColor: '#ffffff',
outline: 'none',
width: '100%',
boxSizing: 'border-box',
resize: 'vertical',
fontFamily: 'inherit',
}}
/>
</div>
);
};
/* ---------- Settings panel ---------- */
const TextareaFieldSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as TextareaFieldProps,
}));
const rowsPresets = [2, 3, 4, 6, 8];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Label</label>
<input
type="text"
value={props.label || ''}
onChange={(e) => setProp((p: TextareaFieldProps) => { p.label = e.target.value; })}
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 }}>Name</label>
<input
type="text"
value={props.name || ''}
onChange={(e) => setProp((p: TextareaFieldProps) => { p.name = e.target.value; })}
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 }}>Placeholder</label>
<input
type="text"
value={props.placeholder || ''}
onChange={(e) => setProp((p: TextareaFieldProps) => { p.placeholder = e.target.value; })}
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 }}>Rows</label>
<div style={{ display: 'flex', gap: 4 }}>
{rowsPresets.map((r) => (
<button
key={r}
onClick={() => setProp((p: TextareaFieldProps) => { p.rows = r; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.rows === r ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{r}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 10, color: '#a1a1aa', display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
checked={!!props.required}
onChange={(e) => setProp((p: TextareaFieldProps) => { p.required = e.target.checked; })}
/>
Required
</label>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
TextareaField.craft = {
displayName: 'Textarea',
props: {
label: 'Message',
name: 'message',
placeholder: 'Enter your message',
rows: 4,
required: false,
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: TextareaFieldSettings,
},
};
/* ---------- HTML export ---------- */
(TextareaField as any).toHtml = (props: TextareaFieldProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const wrapStyle = cssPropsToString({
display: 'flex',
flexDirection: 'column',
gap: '4px',
...props.style,
});
const reqAttr = props.required ? ' required' : '';
const labelHtml = props.label
? `<label style="font-size:14px;font-weight:500;color:#18181b">${esc(props.label)}${props.required ? '<span style="color:#ef4444"> *</span>' : ''}</label>`
: '';
return {
html: `<div${wrapStyle ? ` style="${wrapStyle}"` : ''}>
${labelHtml}
<textarea name="${esc(props.name || 'message')}" placeholder="${esc(props.placeholder || '')}" rows="${props.rows || 4}"${reqAttr} style="padding:10px 12px;border:1px solid #d4d4d8;border-radius:6px;font-size:14px;color:#18181b;background-color:#ffffff;width:100%;box-sizing:border-box;resize:vertical;font-family:inherit"></textarea>
</div>`,
};
};

View File

@@ -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>`,
};
};

View File

@@ -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>`,
};
};

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}>` };
};

View File

@@ -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>` };
};

View File

@@ -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>` };
};

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>`,
};
};

View File

@@ -0,0 +1,480 @@
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
const PLACEHOLDER_SRC = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='300'%3E%3Cdefs%3E%3ClinearGradient id='bg' x1='0' y1='0' x2='0' y2='1'%3E%3Cstop offset='0%25' stop-color='%23f1f5f9'/%3E%3Cstop offset='100%25' stop-color='%23e2e8f0'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect fill='url(%23bg)' width='400' height='300' rx='12'/%3E%3Crect x='2' y='2' width='396' height='296' rx='10' fill='none' stroke='%23cbd5e1' stroke-width='2' stroke-dasharray='8 4'/%3E%3Cg transform='translate(200,110)'%3E%3Crect x='-28' y='-28' width='56' height='56' rx='12' fill='%23cbd5e1' opacity='0.5'/%3E%3Cpath d='M-12 8 L-4 -2 L2 4 L8 -6 L16 8Z' fill='%2394a3b8'/%3E%3Ccircle cx='-6' cy='-10' r='5' fill='%2394a3b8'/%3E%3C/g%3E%3Ctext x='200' y='160' text-anchor='middle' fill='%2364748b' font-family='Inter,sans-serif' font-size='15' font-weight='500'%3EDrop image here%3C/text%3E%3Ctext x='200' y='182' text-anchor='middle' fill='%2394a3b8' font-family='Inter,sans-serif' font-size='12'%3Eor click to upload%3C/text%3E%3C/svg%3E";
interface ImageBlockProps {
src?: string;
alt?: string;
style?: CSSProperties;
}
// Helper: upload a file to the WHP API and return the proxy URL
async function uploadToWhp(file: File): Promise<string | null> {
const cfg = (window as any).WHP_CONFIG;
if (!cfg) return URL.createObjectURL(file); // Standalone fallback
const formData = new FormData();
formData.append('file', file);
try {
const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, {
method: 'POST',
headers: { 'X-CSRF-Token': cfg.csrfToken },
body: formData,
});
const data = await resp.json();
if (data.success && data.url) return data.url;
return null;
} catch { return null; }
}
export const ImageBlock: UserComponent<ImageBlockProps> = ({
src = PLACEHOLDER_SRC,
alt = '',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
actions: { setProp },
} = useNode((node) => ({ selected: node.events.selected }));
const imgRef = useRef<HTMLImageElement | null>(null);
const isPlaceholder = !src || src === PLACEHOLDER_SRC || src.startsWith('data:image/svg');
// Handle drag-and-drop of files directly onto the image
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const file = e.dataTransfer.files?.[0];
if (file && file.type.startsWith('image/')) {
const url = await uploadToWhp(file);
if (url) setProp((p: ImageBlockProps) => { p.src = url; });
}
}, [setProp]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}, []);
return (
<img
ref={(ref: HTMLImageElement | null) => {
imgRef.current = ref;
if (ref) connect(drag(ref));
}}
src={src}
alt={alt || 'Image'}
onDrop={handleDrop}
onDragOver={handleDragOver}
style={{
display: 'block',
maxWidth: '100%',
outline: 'none',
cursor: selected ? 'move' : 'pointer',
...style,
}}
/>
);
};
/* ---------- Helpers for parsing CSS unit values ---------- */
type SizeUnit = 'px' | '%' | 'auto';
function parseSizeValue(value: string | number | undefined): { num: string; unit: SizeUnit } {
if (!value || value === 'auto') return { num: '', unit: 'auto' };
const str = String(value);
if (str === 'auto') return { num: '', unit: 'auto' };
const match = str.match(/^(\d+(?:\.\d+)?)\s*(px|%)$/);
if (match) return { num: match[1], unit: match[2] as SizeUnit };
// Pure number = px
if (/^\d+(?:\.\d+)?$/.test(str)) return { num: str, unit: 'px' };
return { num: '', unit: 'px' };
}
function buildSizeString(num: string, unit: SizeUnit): string | undefined {
if (unit === 'auto') return 'auto';
if (!num) return undefined;
return `${num}${unit}`;
}
type Alignment = 'left' | 'center' | 'right';
function detectAlignment(style: CSSProperties | undefined): Alignment {
if (!style) return 'left';
const ml = style.marginLeft;
const mr = style.marginRight;
if (ml === 'auto' && mr === 'auto') return 'center';
if (ml === 'auto' && mr !== 'auto') return 'right';
return 'left';
}
/* ---------- Settings panel ---------- */
const ImageBlockSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as ImageBlockProps,
}));
const isPlaceholder = !props.src || props.src === PLACEHOLDER_SRC || props.src?.startsWith('data:image/svg');
const fileInputRef = useRef<HTMLInputElement>(null);
const [showBrowser, setShowBrowser] = useState(false);
const [browserAssets, setBrowserAssets] = useState<any[]>([]);
const [browserLoading, setBrowserLoading] = useState(false);
// Sizing unit state
const widthParsed = parseSizeValue(props.style?.width);
const [widthUnit, setWidthUnit] = useState<SizeUnit>(widthParsed.unit === 'auto' ? 'px' : widthParsed.unit);
const heightParsed = parseSizeValue(props.style?.height);
const [heightUnit, setHeightUnit] = useState<SizeUnit>(heightParsed.unit === 'auto' ? 'px' : heightParsed.unit);
const maxWidthParsed = parseSizeValue(props.style?.maxWidth);
const [maxWidthUnit, setMaxWidthUnit] = useState<SizeUnit>(maxWidthParsed.unit === 'auto' ? '%' : maxWidthParsed.unit);
const alignment = detectAlignment(props.style);
const handleUpload = useCallback(async (file: File) => {
const url = await uploadToWhp(file);
if (url) setProp((p: ImageBlockProps) => { p.src = url; });
}, [setProp]);
const handleBrowse = useCallback(async () => {
if (showBrowser) { setShowBrowser(false); return; }
const cfg = (window as any).WHP_CONFIG;
if (!cfg) return;
setBrowserLoading(true);
try {
const resp = await fetch(`${cfg.apiUrl}?action=list_assets&site_id=${cfg.siteId}`);
const data = await resp.json();
if (data.success && Array.isArray(data.assets)) {
const images = data.assets.filter((a: any) => (a.type || '').startsWith('image'));
setBrowserAssets(images);
setShowBrowser(true);
}
} catch (e) {
console.error('Browse failed:', e);
} finally {
setBrowserLoading(false);
}
}, [showBrowser]);
const radiusPresets = ['0', '4px', '8px', '16px', '50%'];
const setPropStyle = useCallback((key: string, value: string | undefined) => {
setProp((p: ImageBlockProps) => {
p.style = { ...p.style, [key]: value };
});
}, [setProp]);
const setAlignment = useCallback((align: Alignment) => {
setProp((p: ImageBlockProps) => {
const s = { ...p.style };
if (align === 'center') {
s.marginLeft = 'auto';
s.marginRight = 'auto';
s.display = 'block';
} else if (align === 'right') {
s.marginLeft = 'auto';
s.marginRight = undefined;
s.display = 'block';
} else {
s.marginLeft = undefined;
s.marginRight = undefined;
s.display = 'block';
}
p.style = s;
});
}, [setProp]);
// Extract friendly filename from URL
const getFriendlyName = (src: string) => {
const match = src.match(/filename=([^&]+)/);
if (match) return decodeURIComponent(match[1]).replace(/^\d+_[a-f0-9]+_/, '');
return src.split('/').pop() || 'image';
};
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
const inputStyle: CSSProperties = { flex: 1, minWidth: 0, padding: '4px 6px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 };
const selectStyle: CSSProperties = { padding: '4px 2px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11, cursor: 'pointer' };
const btnStyle = (active: boolean): CSSProperties => ({
flex: 1, padding: '4px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: active ? '#3b82f6' : '#27272a',
color: active ? '#fff' : '#a1a1aa',
});
return (
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
{/* Image preview */}
<div>
<label style={labelStyle}>Image Source</label>
{!isPlaceholder ? (
<>
{/* Current image thumbnail + filename + remove */}
<div style={{ marginBottom: 8, borderRadius: 6, overflow: 'hidden', border: '1px solid #3f3f46', position: 'relative' }}>
<img src={props.src} alt="" style={{ width: '100%', height: 'auto', display: 'block', maxHeight: 150, objectFit: 'cover' }} />
<button onClick={() => setProp((p: ImageBlockProps) => { p.src = PLACEHOLDER_SRC; })}
style={{ position: 'absolute', top: 4, right: 4, width: 24, height: 24, borderRadius: '50%', background: 'rgba(0,0,0,0.7)', border: 'none', color: '#fff', cursor: 'pointer', fontSize: 12, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
title="Remove image">
<i className="fa fa-times" />
</button>
</div>
<div style={{ fontSize: 11, color: '#a1a1aa', marginBottom: 8, display: 'flex', alignItems: 'center', gap: 4 }}>
<i className="fa fa-check-circle" style={{ color: '#10b981' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{getFriendlyName(props.src || '')}</span>
</div>
</>
) : (
/* Drop zone when no image set */
<div
style={{ padding: '20px 12px', border: '2px dashed #3f3f46', borderRadius: 6, textAlign: 'center', color: '#71717a', fontSize: 12, cursor: 'pointer', marginBottom: 8, transition: 'border-color 0.15s' }}
onDragOver={(e) => { e.preventDefault(); e.currentTarget.style.borderColor = '#3b82f6'; }}
onDragLeave={(e) => { e.currentTarget.style.borderColor = '#3f3f46'; }}
onDrop={async (e) => {
e.preventDefault();
e.currentTarget.style.borderColor = '#3f3f46';
const file = e.dataTransfer.files?.[0];
if (file && file.type.startsWith('image/')) await handleUpload(file);
}}
onClick={() => fileInputRef.current?.click()}
>
<i className="fa fa-cloud-upload" style={{ fontSize: 24, display: 'block', marginBottom: 6, color: '#3b82f6' }} />
Drop image here or click to upload
</div>
)}
{/* Action buttons: Upload + Browse */}
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => fileInputRef.current?.click()}
style={{ flex: 1, padding: '8px 10px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#3b82f6', color: '#fff', fontWeight: 500 }}
>
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
</button>
<button
onClick={handleBrowse}
style={{ flex: 1, padding: '8px 10px', fontSize: 12, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: showBrowser ? '#3b82f6' : '#27272a', color: showBrowser ? '#fff' : '#e4e4e7' }}
>
<i className={`fa ${browserLoading ? 'fa-spinner fa-spin' : 'fa-folder-open'}`} style={{ marginRight: 4 }} /> Browse
</button>
</div>
{/* Inline asset browser grid */}
{showBrowser && (
<div style={{ maxHeight: 200, overflowY: 'auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4, marginTop: 8, background: '#18181b', borderRadius: 6, padding: 4 }}>
{browserAssets.map(asset => (
<div
key={asset.name}
onClick={() => { setProp((p: ImageBlockProps) => { p.src = asset.url; }); setShowBrowser(false); }}
style={{ cursor: 'pointer', borderRadius: 4, overflow: 'hidden', border: '2px solid transparent', aspectRatio: '1', transition: 'border-color 0.15s' }}
onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#3b82f6'; }}
onMouseLeave={(e) => { e.currentTarget.style.borderColor = 'transparent'; }}
>
<img src={asset.url} alt={asset.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }} />
</div>
))}
{browserAssets.length === 0 && (
<p style={{ gridColumn: '1 / -1', textAlign: 'center', color: '#71717a', fontSize: 11, padding: '12px 0', margin: 0 }}>No images uploaded yet. Use Upload above.</p>
)}
</div>
)}
<input ref={fileInputRef} type="file" accept="image/*" style={{ display: 'none' }}
onChange={(e) => { const file = e.target.files?.[0]; if (file) handleUpload(file); e.target.value = ''; }} />
{/* URL input (collapsed, for advanced users) */}
<div style={{ marginTop: 6 }}>
<input type="text"
value={isPlaceholder ? '' : (props.src || '')}
onChange={(e) => setProp((p: ImageBlockProps) => { p.src = e.target.value || PLACEHOLDER_SRC; })}
placeholder="Or paste image URL..."
style={{ width: '100%', padding: '4px 8px', background: '#1e1e2a', color: '#71717a', border: '1px solid #27272a', borderRadius: 4, fontSize: 10 }}
/>
</div>
</div>
{/* Alt Text */}
<div>
<label style={labelStyle}>Alt Text</label>
<input
type="text"
value={props.alt || ''}
onChange={(e) => setProp((p: ImageBlockProps) => { p.alt = e.target.value; })}
placeholder="Describe the image..."
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
{/* Width */}
<div>
<label style={labelStyle}>Width</label>
<div style={{ display: 'flex', gap: 4 }}>
<input
type="number"
min={0}
value={widthParsed.num}
disabled={props.style?.width === 'auto'}
onChange={(e) => {
const val = buildSizeString(e.target.value, widthUnit);
setPropStyle('width', val || 'auto');
}}
placeholder="auto"
style={inputStyle}
/>
<select
value={props.style?.width === 'auto' ? 'auto' : widthUnit}
onChange={(e) => {
const unit = e.target.value as SizeUnit;
if (unit === 'auto') {
setPropStyle('width', 'auto');
} else {
setWidthUnit(unit);
const num = widthParsed.num || '100';
setPropStyle('width', `${num}${unit}`);
}
}}
style={selectStyle}
>
<option value="px">px</option>
<option value="%">%</option>
<option value="auto">auto</option>
</select>
</div>
</div>
{/* Max Width */}
<div>
<label style={labelStyle}>Max Width</label>
<div style={{ display: 'flex', gap: 4 }}>
<input
type="number"
min={0}
value={maxWidthParsed.num}
onChange={(e) => {
const val = buildSizeString(e.target.value, maxWidthUnit);
setPropStyle('maxWidth', val || '100%');
}}
placeholder="100%"
style={inputStyle}
/>
<select
value={maxWidthUnit}
onChange={(e) => {
const unit = e.target.value as SizeUnit;
setMaxWidthUnit(unit);
const num = maxWidthParsed.num || '100';
setPropStyle('maxWidth', `${num}${unit}`);
}}
style={selectStyle}
>
<option value="px">px</option>
<option value="%">%</option>
</select>
</div>
</div>
{/* Height */}
<div>
<label style={labelStyle}>Height</label>
<div style={{ display: 'flex', gap: 4 }}>
<input
type="number"
min={0}
value={heightParsed.num}
disabled={props.style?.height === 'auto'}
onChange={(e) => {
const val = buildSizeString(e.target.value, heightUnit);
setPropStyle('height', val || 'auto');
}}
placeholder="auto"
style={inputStyle}
/>
<select
value={props.style?.height === 'auto' ? 'auto' : heightUnit}
onChange={(e) => {
const unit = e.target.value as SizeUnit;
if (unit === 'auto') {
setPropStyle('height', 'auto');
} else {
setHeightUnit(unit);
const num = heightParsed.num || '300';
setPropStyle('height', `${num}${unit}`);
}
}}
style={selectStyle}
>
<option value="px">px</option>
<option value="auto">auto</option>
</select>
</div>
</div>
{/* Object Fit (visible when both width and height are explicit values) */}
{props.style?.width && props.style.width !== 'auto' && props.style?.height && props.style.height !== 'auto' && (
<div>
<label style={labelStyle}>Object Fit</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['cover', 'contain', 'fill', 'none'] as const).map((fit) => (
<button
key={fit}
onClick={() => setPropStyle('objectFit', fit)}
style={btnStyle(props.style?.objectFit === fit)}
>
{fit}
</button>
))}
</div>
</div>
)}
{/* Alignment */}
<div>
<label style={labelStyle}>Alignment</label>
<div style={{ display: 'flex', gap: 4 }}>
<button onClick={() => setAlignment('left')} style={btnStyle(alignment === 'left')}>
<i className="fa fa-align-left" style={{ marginRight: 3 }} />Left
</button>
<button onClick={() => setAlignment('center')} style={btnStyle(alignment === 'center')}>
<i className="fa fa-align-center" style={{ marginRight: 3 }} />Center
</button>
<button onClick={() => setAlignment('right')} style={btnStyle(alignment === 'right')}>
<i className="fa fa-align-right" style={{ marginRight: 3 }} />Right
</button>
</div>
</div>
{/* Border Radius */}
<div>
<label style={labelStyle}>Border Radius</label>
<div style={{ display: 'flex', gap: 4 }}>
{radiusPresets.map((r) => (
<button key={r} onClick={() => setPropStyle('borderRadius', r)}
style={btnStyle(props.style?.borderRadius === r)}
>{r}</button>
))}
</div>
</div>
</div>
);
};
ImageBlock.craft = {
displayName: 'Image',
props: { src: PLACEHOLDER_SRC, alt: '', style: { width: '100%', height: 'auto' } },
rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true },
related: { settings: ImageBlockSettings },
};
(ImageBlock as any).toHtml = (props: ImageBlockProps, _c: string) => {
// Skip placeholder/empty images in export
const src = props.src || '';
if (!src || src.startsWith('data:image/svg') || src === PLACEHOLDER_SRC) {
return { html: '' };
}
const s = cssPropsToString({ display: 'block', maxWidth: '100%', ...props.style });
const alt = props.alt ? ` alt="${props.alt.replace(/"/g, '&quot;')}"` : ' alt=""';
return { html: `<img src="${src}"${alt}${s ? ` style="${s}"` : ''} />` };
};

View File

@@ -0,0 +1,173 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface MapEmbedProps {
address?: string;
zoom?: number;
height?: string;
style?: CSSProperties;
}
function buildMapUrl(address: string, zoom: number): string {
const encoded = encodeURIComponent(address);
return `https://maps.google.com/maps?q=${encoded}&z=${zoom}&output=embed`;
}
export const MapEmbed: UserComponent<MapEmbedProps> = ({
address = 'New York, NY',
zoom = 14,
height = '400px',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<div
ref={(ref: HTMLDivElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
width: '100%',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
<iframe
src={buildMapUrl(address, zoom)}
style={{
width: '100%',
height,
border: 'none',
borderRadius: (style as any)?.borderRadius || '0px',
display: 'block',
}}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
allowFullScreen
/>
</div>
);
};
/* ---------- Settings panel ---------- */
const MapEmbedSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as MapEmbedProps,
}));
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
const inputStyle: CSSProperties = {
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
};
const heightPresets = ['300px', '400px', '500px', '600px'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Address */}
<div>
<label style={labelStyle}>Address</label>
<input
type="text"
value={props.address || ''}
onChange={(e) => setProp((p: MapEmbedProps) => { p.address = e.target.value; })}
placeholder="Enter an address or location..."
style={inputStyle}
/>
</div>
{/* Zoom */}
<div>
<label style={labelStyle}>Zoom: {props.zoom || 14}</label>
<input
type="range"
min={1}
max={20}
value={props.zoom || 14}
onChange={(e) => setProp((p: MapEmbedProps) => { p.zoom = parseInt(e.target.value, 10); })}
style={{ width: '100%' }}
/>
</div>
{/* Height */}
<div>
<label style={labelStyle}>Height</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', marginBottom: 6 }}>
{heightPresets.map((h) => (
<button
key={h}
onClick={() => setProp((p: MapEmbedProps) => { p.height = h; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.height === h ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{h}
</button>
))}
</div>
<input
type="text"
value={props.height || ''}
onChange={(e) => setProp((p: MapEmbedProps) => { p.height = e.target.value; })}
placeholder="e.g. 400px"
style={inputStyle}
/>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
MapEmbed.craft = {
displayName: 'Map',
props: {
address: 'New York, NY',
zoom: 14,
height: '400px',
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: MapEmbedSettings,
},
};
/* ---------- HTML export ---------- */
(MapEmbed as any).toHtml = (props: MapEmbedProps, _childrenHtml: string) => {
const {
address = 'New York, NY',
zoom = 14,
height = '400px',
style = {},
} = props;
const wrapperStyle = cssPropsToString({ width: '100%', ...style });
const iframeStyle = cssPropsToString({
width: '100%',
height,
border: 'none',
borderRadius: (style as any)?.borderRadius || undefined,
display: 'block',
});
const src = buildMapUrl(address, zoom);
return {
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><iframe src="${src}" loading="lazy" referrerpolicy="no-referrer-when-downgrade" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div>`,
};
};

View File

@@ -0,0 +1,794 @@
import React, { CSSProperties, useCallback, useRef, useState } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from '../layout/Container';
import { cssPropsToString } from '../../utils/style-helpers';
/* ---------- Types ---------- */
type VideoType = 'youtube' | 'vimeo' | 'file' | 'none';
interface VideoBlockProps {
videoUrl?: string;
videoType?: VideoType;
embedUrl?: string;
autoplay?: boolean;
muted?: boolean;
loop?: boolean;
controls?: boolean;
isBackground?: boolean;
overlayColor?: string;
overlayOpacity?: number;
innerMaxWidth?: string;
style?: CSSProperties;
children?: React.ReactNode;
}
/* ---------- URL detection ---------- */
function detectVideoType(url: string): { type: VideoType; embedUrl: string } {
if (!url) return { type: 'none', embedUrl: '' };
// YouTube
const ytMatch = url.match(
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]+)/
);
if (ytMatch) return { type: 'youtube', embedUrl: `https://www.youtube.com/embed/${ytMatch[1]}?rel=0` };
// Vimeo
const vmMatch = url.match(/vimeo\.com\/(\d+)/);
if (vmMatch) return { type: 'vimeo', embedUrl: `https://player.vimeo.com/video/${vmMatch[1]}` };
// Direct file
if (url.match(/\.(mp4|webm|ogg|mov)(\?|$)/i)) return { type: 'file', embedUrl: url };
// Uploaded asset (proxy URL)
if (url.includes('assets-proxy') || url.includes('serve_asset')) return { type: 'file', embedUrl: url };
return { type: 'none', embedUrl: url };
}
/** Build embed params for YouTube/Vimeo iframes */
function buildEmbedParams(
baseUrl: string,
opts: { autoplay?: boolean; muted?: boolean; loop?: boolean; controls?: boolean }
): string {
const url = new URL(baseUrl);
if (opts.autoplay) url.searchParams.set('autoplay', '1');
if (opts.muted) url.searchParams.set('mute', '1');
if (opts.loop) url.searchParams.set('loop', '1');
if (opts.controls === false) url.searchParams.set('controls', '0');
return url.toString();
}
/* ---------- Upload helper ---------- */
async function uploadToWhp(file: File): Promise<string | null> {
const cfg = (window as any).WHP_CONFIG;
if (!cfg) return URL.createObjectURL(file);
const formData = new FormData();
formData.append('file', file);
try {
const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, {
method: 'POST',
headers: { 'X-CSRF-Token': cfg.csrfToken },
body: formData,
});
const data = await resp.json();
if (data.success && data.url) return data.url;
return null;
} catch {
return null;
}
}
/* ---------- Placeholder ---------- */
const VIDEO_PLACEHOLDER = (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
gap: 8,
width: '100%',
aspectRatio: '16 / 9',
background: '#27272a',
borderRadius: 8,
border: '2px dashed #3f3f46',
color: '#71717a',
fontFamily: 'sans-serif',
fontSize: 14,
textAlign: 'center' as const,
}}
>
<i className="fa fa-play-circle" style={{ fontSize: 36, opacity: 0.5 }} />
<span>Add a video URL in settings</span>
</div>
);
/* ========================================================================
Normal (non-background) Video Component
======================================================================== */
export const VideoBlock: UserComponent<VideoBlockProps> = ({
videoUrl = '',
videoType: _videoTypeProp,
embedUrl: _embedUrlProp,
autoplay = false,
muted = true,
loop = false,
controls = true,
isBackground = false,
overlayColor = '#000000',
overlayOpacity = 50,
innerMaxWidth = '1200px',
style = {},
}) => {
const {
connectors: { connect, drag },
} = useNode();
// Detect type from URL
const { type, embedUrl } = videoUrl ? detectVideoType(videoUrl) : { type: 'none' as VideoType, embedUrl: '' };
/* ---- Background mode ---- */
if (isBackground) {
return (
<section
ref={(ref: HTMLElement | null): void => {
if (ref) connect(drag(ref));
}}
style={{
position: 'relative',
width: '100%',
minHeight: '300px',
overflow: 'hidden',
...style,
}}
>
{/* Background video layer */}
{type === 'file' && embedUrl && (
<video
src={embedUrl}
autoPlay
muted
loop
playsInline
style={{
position: 'absolute',
top: '50%',
left: '50%',
minWidth: '100%',
minHeight: '100%',
width: 'auto',
height: 'auto',
transform: 'translate(-50%, -50%)',
objectFit: 'cover',
zIndex: 0,
pointerEvents: 'none',
}}
/>
)}
{(type === 'youtube' || type === 'vimeo') && embedUrl && (
<iframe
src={buildEmbedParams(embedUrl, { autoplay: true, muted: true, loop: true, controls: false })}
style={{
position: 'absolute',
top: '50%',
left: '50%',
width: '177.78vh', // 16:9 ratio overflow
height: '100vh',
minWidth: '100%',
minHeight: '100%',
transform: 'translate(-50%, -50%)',
border: 'none',
zIndex: 0,
pointerEvents: 'none',
}}
allow="autoplay; encrypted-media"
allowFullScreen
/>
)}
{type === 'none' && (
<div
style={{
position: 'absolute',
inset: 0,
background: '#1e293b',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#71717a',
fontSize: 14,
fontFamily: 'sans-serif',
zIndex: 0,
}}
>
<i className="fa fa-film" style={{ fontSize: 48, opacity: 0.3 }} />
</div>
)}
{/* Overlay */}
<div
style={{
position: 'absolute',
inset: 0,
backgroundColor: overlayColor,
opacity: (overlayOpacity ?? 50) / 100,
zIndex: 1,
pointerEvents: 'none',
}}
/>
{/* Content drop zone */}
<Element
id="video-bg-inner"
is={Container}
canvas
style={{
position: 'relative',
zIndex: 2,
maxWidth: innerMaxWidth,
margin: '0 auto',
padding: '80px 20px',
}}
tag="div"
/>
</section>
);
}
/* ---- Normal mode ---- */
return (
<div
ref={(ref: HTMLDivElement | null): void => {
if (ref) connect(drag(ref));
}}
style={{
width: '100%',
...style,
}}
>
{type === 'none' && VIDEO_PLACEHOLDER}
{(type === 'youtube' || type === 'vimeo') && (
<div
style={{
position: 'relative',
paddingBottom: '56.25%',
height: 0,
overflow: 'hidden',
borderRadius: (style as any)?.borderRadius || undefined,
}}
>
<iframe
src={buildEmbedParams(embedUrl, { autoplay, muted, loop, controls })}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
border: 'none',
}}
allow="autoplay; encrypted-media; picture-in-picture"
allowFullScreen
/>
</div>
)}
{type === 'file' && (
<video
src={embedUrl}
autoPlay={autoplay}
muted={muted}
loop={loop}
controls={controls}
playsInline
style={{
display: 'block',
width: '100%',
borderRadius: (style as any)?.borderRadius || undefined,
}}
/>
)}
</div>
);
};
/* ========================================================================
Settings Panel
======================================================================== */
const VideoBlockSettings: React.FC = () => {
const {
actions: { setProp },
props,
} = useNode((node) => ({
props: node.data.props as VideoBlockProps,
}));
const [urlInput, setUrlInput] = useState(props.videoUrl || '');
const fileInputRef = useRef<HTMLInputElement>(null);
const detected = props.videoUrl ? detectVideoType(props.videoUrl) : { type: 'none' as VideoType, embedUrl: '' };
const applyUrl = useCallback(
(url: string) => {
const info = detectVideoType(url);
setProp((p: VideoBlockProps) => {
p.videoUrl = url;
p.videoType = info.type;
p.embedUrl = info.embedUrl;
});
},
[setProp]
);
const handleUpload = useCallback(
async (file: File) => {
const url = await uploadToWhp(file);
if (url) {
setUrlInput(url);
applyUrl(url);
}
},
[applyUrl]
);
const handleBrowse = useCallback(async () => {
const cfg = (window as any).WHP_CONFIG;
if (!cfg) return;
try {
const resp = await fetch(`${cfg.apiUrl}?action=list_assets&site_id=${cfg.siteId}`);
const data = await resp.json();
if (data.success && Array.isArray(data.assets)) {
const videos = data.assets.filter(
(a: any) => (a.type || '').startsWith('video') || (a.name || '').match(/\.(mp4|webm|ogg|mov)$/i)
);
if (videos.length === 0) {
alert('No video assets uploaded yet. Use the Upload button to add one.');
return;
}
const names = videos.map((a: any, i: number) => `${i + 1}. ${a.name}`).join('\n');
const choice = prompt(`Select a video (enter number):\n\n${names}`);
if (choice) {
const idx = parseInt(choice, 10) - 1;
if (videos[idx]) {
setUrlInput(videos[idx].url);
applyUrl(videos[idx].url);
}
}
}
} catch (e) {
console.error('Browse failed:', e);
}
}, [applyUrl]);
const typeBadge = (label: string, color: string) => (
<span
style={{
display: 'inline-block',
padding: '2px 8px',
borderRadius: 4,
background: color,
color: '#fff',
fontSize: 10,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
{label}
</span>
);
const overlayPresets = ['#000000', '#1e293b', '#0f172a', '#312e81', '#064e3b', '#7f1d1d'];
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4 };
const inputStyle: CSSProperties = {
width: '100%',
padding: '4px 8px',
background: '#27272a',
color: '#e4e4e7',
border: '1px solid #3f3f46',
borderRadius: 4,
fontSize: 12,
};
const checkboxRowStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 12,
color: '#e4e4e7',
cursor: 'pointer',
};
return (
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 14 }}>
{/* Video URL */}
<div>
<label style={labelStyle}>Video URL</label>
<div style={{ display: 'flex', gap: 4 }}>
<input
type="text"
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') applyUrl(urlInput);
}}
placeholder="YouTube, Vimeo, or direct video URL..."
style={{ ...inputStyle, flex: 1 }}
/>
<button
onClick={() => applyUrl(urlInput)}
style={{
padding: '4px 12px',
fontSize: 11,
borderRadius: 4,
cursor: 'pointer',
border: '1px solid #3f3f46',
background: '#3b82f6',
color: '#fff',
fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
Apply
</button>
</div>
{/* Detected type badge */}
{detected.type !== 'none' && (
<div style={{ marginTop: 6 }}>
{detected.type === 'youtube' && typeBadge('YouTube', '#dc2626')}
{detected.type === 'vimeo' && typeBadge('Vimeo', '#1ab7ea')}
{detected.type === 'file' && typeBadge('Video File', '#16a34a')}
</div>
)}
</div>
{/* Upload / Browse */}
<div>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => fileInputRef.current?.click()}
style={{
flex: 1,
padding: '8px 10px',
fontSize: 12,
borderRadius: 4,
cursor: 'pointer',
border: '1px solid #3f3f46',
background: '#3b82f6',
color: '#fff',
fontWeight: 500,
}}
>
<i className="fa fa-upload" style={{ marginRight: 4 }} /> Upload
</button>
<button
onClick={handleBrowse}
style={{
flex: 1,
padding: '8px 10px',
fontSize: 12,
borderRadius: 4,
cursor: 'pointer',
border: '1px solid #3f3f46',
background: '#27272a',
color: '#e4e4e7',
}}
>
<i className="fa fa-folder-open" style={{ marginRight: 4 }} /> Browse
</button>
</div>
<input
ref={fileInputRef}
type="file"
accept="video/*"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUpload(file);
e.target.value = '';
}}
/>
</div>
{/* Playback options */}
<div>
<label style={labelStyle}>Playback</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<label style={checkboxRowStyle}>
<input
type="checkbox"
checked={props.autoplay ?? false}
onChange={(e) => setProp((p: VideoBlockProps) => { p.autoplay = e.target.checked; })}
/>
Autoplay
</label>
<label style={checkboxRowStyle}>
<input
type="checkbox"
checked={props.muted ?? true}
onChange={(e) => setProp((p: VideoBlockProps) => { p.muted = e.target.checked; })}
/>
Muted
</label>
<label style={checkboxRowStyle}>
<input
type="checkbox"
checked={props.loop ?? false}
onChange={(e) => setProp((p: VideoBlockProps) => { p.loop = e.target.checked; })}
/>
Loop
</label>
<label style={checkboxRowStyle}>
<input
type="checkbox"
checked={props.controls ?? true}
onChange={(e) => setProp((p: VideoBlockProps) => { p.controls = e.target.checked; })}
/>
Show Controls
</label>
</div>
</div>
{/* Mode toggle */}
<div>
<label style={labelStyle}>Display Mode</label>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => setProp((p: VideoBlockProps) => { p.isBackground = false; })}
style={{
flex: 1,
padding: '6px 10px',
fontSize: 11,
borderRadius: 4,
cursor: 'pointer',
border: '1px solid #3f3f46',
background: !props.isBackground ? '#3b82f6' : '#27272a',
color: !props.isBackground ? '#fff' : '#a1a1aa',
fontWeight: 500,
}}
>
Normal
</button>
<button
onClick={() => setProp((p: VideoBlockProps) => { p.isBackground = true; })}
style={{
flex: 1,
padding: '6px 10px',
fontSize: 11,
borderRadius: 4,
cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.isBackground ? '#3b82f6' : '#27272a',
color: props.isBackground ? '#fff' : '#a1a1aa',
fontWeight: 500,
}}
>
Background
</button>
</div>
</div>
{/* Background mode options */}
{props.isBackground && (
<>
<div>
<label style={labelStyle}>Overlay Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{overlayPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: VideoBlockProps) => { 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={labelStyle}>
Overlay Opacity: {props.overlayOpacity ?? 50}%
</label>
<input
type="range"
min={0}
max={100}
value={props.overlayOpacity ?? 50}
onChange={(e) =>
setProp((p: VideoBlockProps) => {
p.overlayOpacity = parseInt(e.target.value, 10);
})
}
style={{ width: '100%' }}
/>
</div>
<div>
<label style={labelStyle}>Inner Max Width</label>
<input
type="text"
value={props.innerMaxWidth || '1200px'}
onChange={(e) => setProp((p: VideoBlockProps) => { p.innerMaxWidth = e.target.value; })}
style={inputStyle}
/>
</div>
</>
)}
</div>
);
};
/* ========================================================================
Craft Config
======================================================================== */
VideoBlock.craft = {
displayName: 'Video',
props: {
videoUrl: '',
videoType: 'none',
embedUrl: '',
autoplay: false,
muted: true,
loop: false,
controls: true,
isBackground: false,
overlayColor: '#000000',
overlayOpacity: 50,
innerMaxWidth: '1200px',
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => true,
canMoveOut: () => true,
},
related: {
settings: VideoBlockSettings,
},
};
/* ========================================================================
HTML Export
======================================================================== */
(VideoBlock as any).toHtml = (props: VideoBlockProps, childrenHtml: string) => {
const {
videoUrl = '',
autoplay = false,
muted = true,
loop: doLoop = false,
controls = true,
isBackground = false,
overlayColor = '#000000',
overlayOpacity = 50,
innerMaxWidth = '1200px',
style = {},
} = props;
const { type, embedUrl } = videoUrl ? detectVideoType(videoUrl) : { type: 'none' as VideoType, embedUrl: '' };
/* ---- Background mode export ---- */
if (isBackground) {
const outerStyle = cssPropsToString({
position: 'relative',
width: '100%',
minHeight: '300px',
overflow: 'hidden',
...style,
});
const overlayStyle = cssPropsToString({
position: 'absolute',
inset: '0',
backgroundColor: overlayColor,
opacity: String(overlayOpacity / 100),
zIndex: '1',
pointerEvents: 'none',
});
const innerStyle = cssPropsToString({
position: 'relative',
zIndex: '2',
maxWidth: innerMaxWidth,
margin: '0 auto',
padding: '80px 20px',
});
let videoHtml = '';
if (type === 'file' && embedUrl) {
const vidStyle = cssPropsToString({
position: 'absolute',
top: '50%',
left: '50%',
minWidth: '100%',
minHeight: '100%',
width: 'auto',
height: 'auto',
transform: 'translate(-50%, -50%)',
objectFit: 'cover',
zIndex: '0',
pointerEvents: 'none',
});
videoHtml = `<video src="${embedUrl}" autoplay muted loop playsinline${vidStyle ? ` style="${vidStyle}"` : ''}></video>`;
} else if ((type === 'youtube' || type === 'vimeo') && embedUrl) {
const iframeSrc = buildEmbedParams(embedUrl, { autoplay: true, muted: true, loop: true, controls: false });
const ifrStyle = cssPropsToString({
position: 'absolute',
top: '50%',
left: '50%',
width: '177.78vh',
height: '100vh',
minWidth: '100%',
minHeight: '100%',
transform: 'translate(-50%, -50%)',
border: 'none',
zIndex: '0',
pointerEvents: 'none',
});
videoHtml = `<iframe src="${iframeSrc}" allow="autoplay; encrypted-media" allowfullscreen${ifrStyle ? ` style="${ifrStyle}"` : ''}></iframe>`;
}
return {
html: `<section${outerStyle ? ` style="${outerStyle}"` : ''}>${videoHtml}<div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></section>`,
};
}
/* ---- Normal mode export ---- */
const wrapperStyle = cssPropsToString({ width: '100%', ...style });
if (type === 'none' || !embedUrl) {
return { html: '' };
}
if (type === 'youtube' || type === 'vimeo') {
const iframeSrc = buildEmbedParams(embedUrl, { autoplay, muted, loop: doLoop, controls });
const containerStyle = cssPropsToString({
position: 'relative',
paddingBottom: '56.25%',
height: '0',
overflow: 'hidden',
borderRadius: (style as any)?.borderRadius || undefined,
});
const iframeStyle = cssPropsToString({
position: 'absolute',
top: '0',
left: '0',
width: '100%',
height: '100%',
border: 'none',
});
return {
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><div${containerStyle ? ` style="${containerStyle}"` : ''}><iframe src="${iframeSrc}" allow="autoplay; encrypted-media; picture-in-picture" allowfullscreen${iframeStyle ? ` style="${iframeStyle}"` : ''}></iframe></div></div>`,
};
}
// Direct file
const vidAttrs: string[] = [];
if (autoplay) vidAttrs.push('autoplay');
if (muted) vidAttrs.push('muted');
if (doLoop) vidAttrs.push('loop');
if (controls) vidAttrs.push('controls');
vidAttrs.push('playsinline');
const vidStyle = cssPropsToString({
display: 'block',
width: '100%',
borderRadius: (style as any)?.borderRadius || undefined,
});
return {
html: `<div${wrapperStyle ? ` style="${wrapperStyle}"` : ''}><video src="${embedUrl}" ${vidAttrs.join(' ')}${vidStyle ? ` style="${vidStyle}"` : ''}></video></div>`,
};
};

View File

@@ -0,0 +1,81 @@
import { Container } from './layout/Container';
import { Section } from './layout/Section';
import { ColumnLayout } from './layout/ColumnLayout';
import { BackgroundSection } from './layout/BackgroundSection';
import { Heading } from './basic/Heading';
import { TextBlock } from './basic/TextBlock';
import { ButtonLink } from './basic/ButtonLink';
import { Logo } from './basic/Logo';
import { Menu } from './basic/Menu';
import { Navbar } from './basic/Navbar';
import { Footer } from './basic/Footer';
import { Divider } from './basic/Divider';
import { Spacer } from './basic/Spacer';
import { Icon } from './basic/Icon';
import { ImageBlock } from './media/ImageBlock';
import { VideoBlock } from './media/VideoBlock';
import { MapEmbed } from './media/MapEmbed';
import { HeroSimple } from './sections/HeroSimple';
import { FeaturesGrid } from './sections/FeaturesGrid';
import { CTASection } from './sections/CTASection';
import { Countdown } from './sections/Countdown';
import { Testimonials } from './sections/Testimonials';
import { FormContainer } from './forms/FormContainer';
import { InputField } from './forms/InputField';
import { TextareaField } from './forms/TextareaField';
import { FormButton } from './forms/FormButton';
import { ContactForm } from './forms/ContactForm';
import { StarRating } from './basic/StarRating';
import { SocialLinks } from './basic/SocialLinks';
import { CallToAction } from './sections/CallToAction';
import { Accordion } from './sections/Accordion';
import { Tabs } from './sections/Tabs';
import { PricingTable } from './sections/PricingTable';
import { Gallery } from './sections/Gallery';
import { ContentSlider } from './sections/ContentSlider';
import { NumberCounter } from './sections/NumberCounter';
import { SubscribeForm } from './forms/SubscribeForm';
import { SearchBar } from './basic/SearchBar';
import { HtmlBlock } from './basic/HtmlBlock';
export const componentResolver = {
Container,
Section,
ColumnLayout,
BackgroundSection,
Heading,
TextBlock,
ButtonLink,
Logo,
Menu,
Navbar,
Footer,
Divider,
Spacer,
Icon,
ImageBlock,
VideoBlock,
MapEmbed,
HeroSimple,
FeaturesGrid,
CTASection,
Countdown,
Testimonials,
FormContainer,
InputField,
TextareaField,
FormButton,
ContactForm,
StarRating,
SocialLinks,
CallToAction,
Accordion,
Tabs,
PricingTable,
Gallery,
ContentSlider,
NumberCounter,
SubscribeForm,
SearchBar,
HtmlBlock,
};

View File

@@ -0,0 +1,330 @@
import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface AccordionItem {
title: string;
content: string;
isOpen?: boolean;
}
interface AccordionProps {
items?: AccordionItem[];
style?: CSSProperties;
headerBg?: string;
headerColor?: string;
contentBg?: string;
borderColor?: string;
}
const defaultItems: AccordionItem[] = [
{ title: 'What is this product?', content: 'Our product is a powerful yet easy-to-use tool designed to help you build beautiful websites without writing a single line of code.', isOpen: true },
{ title: 'How do I get started?', content: 'Simply sign up for a free account, choose a template, and start customizing. Our drag-and-drop editor makes it easy to create professional pages in minutes.', isOpen: false },
{ title: 'Is there a free plan?', content: 'Yes! We offer a generous free tier that includes all core features. Upgrade anytime to unlock advanced capabilities like custom domains and analytics.', isOpen: false },
];
export const Accordion: UserComponent<AccordionProps> = ({
items = defaultItems,
style = {},
headerBg = '#f8fafc',
headerColor = '#18181b',
contentBg = '#ffffff',
borderColor = '#e2e8f0',
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const [openIndexes, setOpenIndexes] = useState<Set<number>>(() => {
const initial = new Set<number>();
items.forEach((item, i) => { if (item.isOpen) initial.add(i); });
return initial;
});
const toggle = (index: number) => {
setOpenIndexes((prev) => {
const next = new Set(prev);
if (next.has(index)) next.delete(index);
else next.add(index);
return next;
});
};
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
padding: '60px 20px',
backgroundColor: '#ffffff',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
<div style={{ maxWidth: '800px', margin: '0 auto', display: 'flex', flexDirection: 'column', gap: '0px' }}>
{items.map((item, i) => {
const isOpen = openIndexes.has(i);
return (
<div
key={i}
style={{
border: `1px solid ${borderColor}`,
borderBottom: i === items.length - 1 ? `1px solid ${borderColor}` : 'none',
...(i === 0 ? { borderTopLeftRadius: '8px', borderTopRightRadius: '8px' } : {}),
...(i === items.length - 1 ? { borderBottomLeftRadius: '8px', borderBottomRightRadius: '8px', borderBottom: `1px solid ${borderColor}` } : {}),
}}
>
<div
onClick={() => toggle(i)}
style={{
padding: '16px 20px',
backgroundColor: headerBg,
color: headerColor,
cursor: 'pointer',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontWeight: '600',
fontSize: '16px',
userSelect: 'none',
...(i === 0 ? { borderTopLeftRadius: '7px', borderTopRightRadius: '7px' } : {}),
...(i === items.length - 1 && !isOpen ? { borderBottomLeftRadius: '7px', borderBottomRightRadius: '7px' } : {}),
}}
>
<span>{item.title}</span>
<span style={{ fontSize: '12px', transition: 'transform 0.2s', transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)' }}>&#9660;</span>
</div>
{isOpen && (
<div
style={{
padding: '16px 20px',
backgroundColor: contentBg,
color: '#4b5563',
fontSize: '14px',
lineHeight: '1.6',
borderTop: `1px solid ${borderColor}`,
...(i === items.length - 1 ? { borderBottomLeftRadius: '7px', borderBottomRightRadius: '7px' } : {}),
}}
>
{item.content}
</div>
)}
</div>
);
})}
</div>
</section>
);
};
/* ---------- Settings panel ---------- */
const AccordionSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as AccordionProps,
}));
const items = props.items || defaultItems;
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
const updateItem = (index: number, field: keyof AccordionItem, value: string | boolean) => {
setProp((p: AccordionProps) => {
const updated = [...(p.items || defaultItems)];
updated[index] = { ...updated[index], [field]: value };
p.items = updated;
});
};
const addItem = () => {
setProp((p: AccordionProps) => {
p.items = [...(p.items || defaultItems), { title: 'New Question', content: 'Answer goes here.', isOpen: false }];
});
};
const removeItem = (index: number) => {
setProp((p: AccordionProps) => {
const updated = [...(p.items || defaultItems)];
updated.splice(index, 1);
p.items = updated;
});
};
const colorSwatches = ['#f8fafc', '#f1f5f9', '#e2e8f0', '#ffffff', '#18181b', '#1e293b', '#3b82f6', '#8b5cf6'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={labelStyle}>Header Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorSwatches.map((c) => (
<button
key={c}
onClick={() => setProp((p: AccordionProps) => { p.headerBg = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.headerBg === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Header Text Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#18181b', '#1f2937', '#374151', '#ffffff', '#e2e8f0', '#3b82f6'].map((c) => (
<button
key={c}
onClick={() => setProp((p: AccordionProps) => { p.headerColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.headerColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Content Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#1e293b'].map((c) => (
<button
key={c}
onClick={() => setProp((p: AccordionProps) => { p.contentBg = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.contentBg === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Border Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#e2e8f0', '#cbd5e1', '#d1d5db', '#3f3f46', '#52525b'].map((c) => (
<button
key={c}
onClick={() => setProp((p: AccordionProps) => { p.borderColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.borderColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Items</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((item, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={item.title} onChange={(e) => updateItem(i, 'title', e.target.value)} placeholder="Title" style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removeItem(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
X
</button>
</div>
<textarea
value={item.content}
onChange={(e) => updateItem(i, 'content', e.target.value)}
placeholder="Content"
rows={2}
style={{ ...inputStyle, resize: 'vertical' }}
/>
</div>
))}
</div>
<button
onClick={addItem}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Item
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Accordion.craft = {
displayName: 'Accordion',
props: {
items: defaultItems,
style: { backgroundColor: '#ffffff' },
headerBg: '#f8fafc',
headerColor: '#18181b',
contentBg: '#ffffff',
borderColor: '#e2e8f0',
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: AccordionSettings,
},
};
/* ---------- HTML export ---------- */
(Accordion as any).toHtml = (props: AccordionProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const sectionStyle = cssPropsToString({
padding: '60px 20px',
...props.style,
});
const headerBg = props.headerBg || '#f8fafc';
const headerColor = props.headerColor || '#18181b';
const contentBg = props.contentBg || '#ffffff';
const borderColor = props.borderColor || '#e2e8f0';
const items = props.items || defaultItems;
const panels = items.map((item, i) => {
const openAttr = item.isOpen ? ' open' : '';
const topRadius = i === 0 ? 'border-top-left-radius:8px;border-top-right-radius:8px;' : '';
const bottomRadius = i === items.length - 1 ? 'border-bottom-left-radius:8px;border-bottom-right-radius:8px;' : '';
const borderBottom = i === items.length - 1 ? `border:1px solid ${borderColor};` : `border:1px solid ${borderColor};border-bottom:none;`;
return `<details${openAttr} style="${borderBottom}${topRadius}${bottomRadius}">
<summary style="padding:16px 20px;background-color:${headerBg};color:${headerColor};cursor:pointer;font-weight:600;font-size:16px;list-style:none;display:flex;justify-content:space-between;align-items:center">
${esc(item.title)}
</summary>
<div style="padding:16px 20px;background-color:${contentBg};color:#4b5563;font-size:14px;line-height:1.6;border-top:1px solid ${borderColor}">
${esc(item.content)}
</div>
</details>`;
}).join('\n ');
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:800px;margin:0 auto;display:flex;flex-direction:column">
${panels}
</div>
<style>details summary::-webkit-details-marker{display:none}details summary::marker{display:none}</style>
</section>`,
};
};

View File

@@ -0,0 +1,192 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface CTASectionProps {
heading?: string;
description?: string;
buttonText?: string;
buttonHref?: string;
gradient?: string;
style?: CSSProperties;
}
const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
export const CTASection: UserComponent<CTASectionProps> = ({
heading = 'Ready to Get Started?',
description = 'Join thousands of satisfied users and start building your dream website today.',
buttonText = 'Start Free Trial',
buttonHref = '#',
gradient = defaultGradient,
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
background: gradient,
padding: '80px 20px',
textAlign: 'center',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
<div style={{ maxWidth: '700px', margin: '0 auto' }}>
<h2 style={{ fontSize: '36px', fontWeight: '700', color: '#ffffff', marginBottom: '12px' }}>
{heading}
</h2>
<p style={{ fontSize: '18px', color: 'rgba(255,255,255,0.85)', marginBottom: '28px', lineHeight: '1.6' }}>
{description}
</p>
<a
href={buttonHref}
onClick={(e) => e.preventDefault()}
style={{
display: 'inline-block',
padding: '14px 36px',
backgroundColor: '#ffffff',
color: '#18181b',
textDecoration: 'none',
borderRadius: '8px',
fontWeight: '600',
fontSize: '16px',
}}
>
{buttonText}
</a>
</div>
</section>
);
};
/* ---------- Settings panel ---------- */
const CTASectionSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as CTASectionProps,
}));
const gradientPresets = [
{ label: 'Blue-Purple', value: 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)' },
{ label: 'Purple', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
{ label: 'Teal', value: 'linear-gradient(135deg, #0d9488 0%, #0f766e 100%)' },
{ label: 'Sunset', value: 'linear-gradient(135deg, #f97316 0%, #ec4899 100%)' },
{ label: 'Dark', value: 'linear-gradient(135deg, #1e293b 0%, #0f172a 100%)' },
{ label: 'Ocean', value: 'linear-gradient(135deg, #0ea5e9 0%, #6366f1 100%)' },
];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Heading</label>
<input
type="text"
value={props.heading || ''}
onChange={(e) => setProp((p: CTASectionProps) => { p.heading = e.target.value; })}
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 }}>Description</label>
<textarea
value={props.description || ''}
onChange={(e) => setProp((p: CTASectionProps) => { p.description = e.target.value; })}
rows={2}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12, resize: 'vertical' }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Text</label>
<input
type="text"
value={props.buttonText || ''}
onChange={(e) => setProp((p: CTASectionProps) => { p.buttonText = e.target.value; })}
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 }}>Button URL</label>
<input
type="text"
value={props.buttonHref || ''}
onChange={(e) => setProp((p: CTASectionProps) => { p.buttonHref = e.target.value; })}
placeholder="https://..."
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 }}>Gradient</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{gradientPresets.map((g) => (
<button
key={g.label}
onClick={() => setProp((p: CTASectionProps) => { p.gradient = g.value; })}
title={g.label}
style={{
width: 32, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
background: g.value, cursor: 'pointer',
outline: props.gradient === g.value ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
CTASection.craft = {
displayName: 'CTA Section',
props: {
heading: 'Ready to Get Started?',
description: 'Join thousands of satisfied users and start building your dream website today.',
buttonText: 'Start Free Trial',
buttonHref: '#',
gradient: defaultGradient,
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: CTASectionSettings,
},
};
/* ---------- HTML export ---------- */
(CTASection as any).toHtml = (props: CTASectionProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const sectionStyle = cssPropsToString({
background: props.gradient || defaultGradient,
padding: '80px 20px',
textAlign: 'center',
...props.style,
});
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:700px;margin:0 auto">
<h2 style="font-size:36px;font-weight:700;color:#ffffff;margin-bottom:12px">${esc(props.heading || '')}</h2>
<p style="font-size:18px;color:rgba(255,255,255,0.85);margin-bottom:28px;line-height:1.6">${esc(props.description || '')}</p>
<a href="${props.buttonHref || '#'}" style="display:inline-block;padding:14px 36px;background-color:#ffffff;color:#18181b;text-decoration:none;border-radius:8px;font-weight:600;font-size:16px">${esc(props.buttonText || '')}</a>
</div>
</section>`,
};
};

View File

@@ -0,0 +1,486 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface CallToActionProps {
heading?: string;
description?: string;
buttonText?: string;
buttonHref?: string;
secondaryButtonText?: string;
secondaryButtonHref?: string;
bgType?: 'color' | 'gradient' | 'image';
bgValue?: string;
overlayColor?: string;
overlayOpacity?: number;
textColor?: string;
buttonColor?: string;
style?: CSSProperties;
}
const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
export const CallToAction: UserComponent<CallToActionProps> = ({
heading = 'Ready to Get Started?',
description = 'Join thousands of satisfied users and start building your dream website today.',
buttonText = 'Get Started',
buttonHref = '#',
secondaryButtonText = '',
secondaryButtonHref = '#',
bgType = 'gradient',
bgValue = defaultGradient,
overlayColor = '#000000',
overlayOpacity = 0,
textColor = '#ffffff',
buttonColor = '#ffffff',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const bgStyle: CSSProperties = {};
if (bgType === 'color') {
bgStyle.backgroundColor = bgValue;
} else if (bgType === 'gradient') {
bgStyle.background = bgValue;
} else if (bgType === 'image') {
bgStyle.backgroundImage = `url(${bgValue})`;
bgStyle.backgroundSize = 'cover';
bgStyle.backgroundPosition = 'center';
}
const isButtonDark = buttonColor === '#ffffff' || buttonColor === '#f8fafc';
const buttonTextColor = isButtonDark ? '#18181b' : '#ffffff';
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
position: 'relative',
padding: '80px 20px',
textAlign: 'center',
outline: selected ? '2px solid #3b82f6' : 'none',
...bgStyle,
...style,
}}
>
{/* Overlay */}
{bgType === 'image' && overlayOpacity > 0 && (
<div
style={{
position: 'absolute',
inset: 0,
backgroundColor: overlayColor,
opacity: overlayOpacity / 100,
pointerEvents: 'none',
}}
/>
)}
<div style={{ maxWidth: '700px', margin: '0 auto', position: 'relative', zIndex: 1 }}>
<h2 style={{ fontSize: '36px', fontWeight: '700', color: textColor, marginBottom: '12px' }}>
{heading}
</h2>
<p style={{ fontSize: '18px', color: textColor, opacity: 0.85, marginBottom: '28px', lineHeight: '1.6' }}>
{description}
</p>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}>
<a
href={buttonHref}
onClick={(e) => e.preventDefault()}
style={{
display: 'inline-block',
padding: '14px 36px',
backgroundColor: buttonColor,
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 CallToActionSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as CallToActionProps,
}));
const gradientPresets = [
{ label: 'Blue-Purple', value: 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)' },
{ label: 'Purple', value: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' },
{ label: 'Teal', value: 'linear-gradient(135deg, #0d9488 0%, #0f766e 100%)' },
{ label: 'Sunset', value: 'linear-gradient(135deg, #f97316 0%, #ec4899 100%)' },
{ label: 'Dark', value: 'linear-gradient(135deg, #1e293b 0%, #0f172a 100%)' },
{ label: 'Ocean', value: 'linear-gradient(135deg, #0ea5e9 0%, #6366f1 100%)' },
];
const colorPresets = ['#2563eb', '#7c3aed', '#0d9488', '#18181b', '#0f172a', '#1e293b', '#dc2626', '#f97316'];
const buttonColorPresets = ['#ffffff', '#18181b', '#3b82f6', '#10b981', '#ef4444', '#8b5cf6', '#f59e0b', '#ec4899'];
const textColorPresets = ['#ffffff', '#f8fafc', '#e2e8f0', '#18181b', '#1e293b', '#fef3c7'];
const inputStyle: CSSProperties = {
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
};
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Heading</label>
<input
type="text"
value={props.heading || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.heading = e.target.value; })}
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Description</label>
<textarea
value={props.description || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.description = e.target.value; })}
rows={2}
style={{ ...inputStyle, resize: 'vertical' }}
/>
</div>
{/* Primary Button */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Primary Button Text</label>
<input
type="text"
value={props.buttonText || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.buttonText = e.target.value; })}
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Primary Button URL</label>
<input
type="text"
value={props.buttonHref || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.buttonHref = e.target.value; })}
placeholder="https://..."
style={inputStyle}
/>
</div>
{/* Secondary Button */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Secondary Button Text <span style={{ opacity: 0.5 }}>(leave empty to hide)</span></label>
<input
type="text"
value={props.secondaryButtonText || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.secondaryButtonText = e.target.value; })}
placeholder="e.g. Learn More"
style={inputStyle}
/>
</div>
{props.secondaryButtonText && (
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Secondary Button URL</label>
<input
type="text"
value={props.secondaryButtonHref || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.secondaryButtonHref = e.target.value; })}
placeholder="https://..."
style={inputStyle}
/>
</div>
)}
{/* Background Type */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Type</label>
<div style={{ display: 'flex', gap: 4 }}>
{(['color', 'gradient', 'image'] as const).map((t) => (
<button
key={t}
onClick={() => {
setProp((p: CallToActionProps) => {
p.bgType = t;
if (t === 'color') p.bgValue = '#2563eb';
if (t === 'gradient') p.bgValue = defaultGradient;
if (t === 'image') p.bgValue = '';
});
}}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.bgType === t ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
textTransform: 'capitalize',
}}
>
{t}
</button>
))}
</div>
</div>
{/* Background sub-controls */}
{props.bgType === 'color' && (
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: CallToActionProps) => { p.bgValue = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.bgValue === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
)}
{props.bgType === 'gradient' && (
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Gradient</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{gradientPresets.map((g) => (
<button
key={g.label}
onClick={() => setProp((p: CallToActionProps) => { p.bgValue = g.value; })}
title={g.label}
style={{
width: 32, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
background: g.value, cursor: 'pointer',
outline: props.bgValue === g.value ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
)}
{props.bgType === 'image' && (
<>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Image URL</label>
<input
type="text"
value={props.bgValue || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.bgValue = e.target.value; })}
placeholder="https://..."
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>
Overlay Opacity: {props.overlayOpacity ?? 0}%
</label>
<input
type="range"
min={0}
max={100}
value={props.overlayOpacity ?? 0}
onChange={(e) => setProp((p: CallToActionProps) => { p.overlayOpacity = parseInt(e.target.value); })}
style={{ width: '100%', accentColor: '#3b82f6' }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Overlay Color</label>
<input
type="color"
value={props.overlayColor || '#000000'}
onChange={(e) => setProp((p: CallToActionProps) => { p.overlayColor = e.target.value; })}
style={{ width: 32, height: 24, border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', background: 'none', padding: 0 }}
/>
</div>
</>
)}
{/* Text Color */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Text Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{textColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: CallToActionProps) => { p.textColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.textColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Button Color */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{buttonColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: CallToActionProps) => { p.buttonColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.buttonColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
CallToAction.craft = {
displayName: 'Call to Action',
props: {
heading: 'Ready to Get Started?',
description: 'Join thousands of satisfied users and start building your dream website today.',
buttonText: 'Get Started',
buttonHref: '#',
secondaryButtonText: 'Learn More',
secondaryButtonHref: '#',
bgType: 'gradient',
bgValue: defaultGradient,
overlayColor: '#000000',
overlayOpacity: 0,
textColor: '#ffffff',
buttonColor: '#ffffff',
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: CallToActionSettings,
},
};
/* ---------- HTML export ---------- */
(CallToAction as any).toHtml = (props: CallToActionProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const bgType = props.bgType || 'gradient';
const bgValue = props.bgValue || defaultGradient;
const textColor = props.textColor || '#ffffff';
const buttonColor = props.buttonColor || '#ffffff';
const isButtonDark = buttonColor === '#ffffff' || buttonColor === '#f8fafc';
const buttonTextColor = isButtonDark ? '#18181b' : '#ffffff';
const sectionCss: CSSProperties = {
position: 'relative',
padding: '80px 20px',
textAlign: 'center',
...props.style,
};
if (bgType === 'color') {
sectionCss.backgroundColor = bgValue;
} else if (bgType === 'gradient') {
sectionCss.background = bgValue;
} else if (bgType === 'image') {
sectionCss.backgroundImage = `url(${bgValue})`;
sectionCss.backgroundSize = 'cover';
sectionCss.backgroundPosition = 'center';
}
const sectionStyle = cssPropsToString(sectionCss);
let overlayHtml = '';
if (bgType === 'image' && (props.overlayOpacity || 0) > 0) {
const overlayStyle = cssPropsToString({
position: 'absolute',
inset: '0',
backgroundColor: props.overlayColor || '#000000',
opacity: String((props.overlayOpacity || 0) / 100) as any,
pointerEvents: 'none',
});
overlayHtml = `<div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div>`;
}
let secondaryBtnHtml = '';
if (props.secondaryButtonText) {
const secStyle = cssPropsToString({
display: 'inline-block',
padding: '14px 36px',
backgroundColor: 'transparent',
color: textColor,
textDecoration: 'none',
borderRadius: '8px',
fontWeight: '600',
fontSize: '16px',
border: `2px solid ${textColor}`,
});
secondaryBtnHtml = `\n <a href="${props.secondaryButtonHref || '#'}"${secStyle ? ` style="${secStyle}"` : ''}>${esc(props.secondaryButtonText)}</a>`;
}
const btnStyle = cssPropsToString({
display: 'inline-block',
padding: '14px 36px',
backgroundColor: buttonColor,
color: buttonTextColor,
textDecoration: 'none',
borderRadius: '8px',
fontWeight: '600',
fontSize: '16px',
});
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
${overlayHtml}<div style="max-width:700px;margin:0 auto;position:relative;z-index:1">
<h2 style="font-size:36px;font-weight:700;color:${textColor};margin-bottom:12px">${esc(props.heading || '')}</h2>
<p style="font-size:18px;color:${textColor};opacity:0.85;margin-bottom:28px;line-height:1.6">${esc(props.description || '')}</p>
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">
<a href="${props.buttonHref || '#'}"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(props.buttonText || '')}</a>${secondaryBtnHtml}
</div>
</div>
</section>`,
};
};

View File

@@ -0,0 +1,530 @@
import React, { CSSProperties, useState, useEffect, useRef, useCallback } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface Slide {
type: 'image' | 'content';
imageSrc?: string;
heading?: string;
text?: string;
buttonText?: string;
buttonHref?: string;
bgColor?: string;
}
interface ContentSliderProps {
slides?: Slide[];
autoplay?: boolean;
interval?: number;
showDots?: boolean;
showArrows?: boolean;
height?: string;
style?: CSSProperties;
}
const defaultSlides: Slide[] = [
{
type: 'image',
imageSrc: '',
heading: 'First Slide',
text: 'Welcome to our showcase',
buttonText: 'Learn More',
buttonHref: '#',
bgColor: 'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)',
},
{
type: 'image',
imageSrc: '',
heading: 'Second Slide',
text: 'Discover something amazing',
buttonText: 'Get Started',
buttonHref: '#',
bgColor: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
},
{
type: 'image',
imageSrc: '',
heading: 'Third Slide',
text: 'Build your future today',
buttonText: 'Contact Us',
buttonHref: '#',
bgColor: 'linear-gradient(135deg, #f59e0b 0%, #ef4444 100%)',
},
];
export const ContentSlider: UserComponent<ContentSliderProps> = ({
slides = defaultSlides,
autoplay = true,
interval = 5000,
showDots = true,
showArrows = true,
height = '400px',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const [activeIndex, setActiveIndex] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const items = slides.length > 0 ? slides : defaultSlides;
const goTo = useCallback((index: number) => {
setActiveIndex(((index % items.length) + items.length) % items.length);
}, [items.length]);
const goNext = useCallback(() => goTo(activeIndex + 1), [activeIndex, goTo]);
const goPrev = useCallback(() => goTo(activeIndex - 1), [activeIndex, goTo]);
useEffect(() => {
if (autoplay && items.length > 1) {
timerRef.current = setInterval(goNext, interval);
return () => { if (timerRef.current) clearInterval(timerRef.current); };
}
}, [autoplay, interval, goNext, items.length]);
const arrowStyle: CSSProperties = {
position: 'absolute',
top: '50%',
transform: 'translateY(-50%)',
width: '40px',
height: '40px',
borderRadius: '50%',
border: 'none',
background: 'rgba(255,255,255,0.9)',
color: '#18181b',
fontSize: '16px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 2,
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
};
const renderSlide = (slide: Slide, i: number) => {
const bg = slide.imageSrc
? { backgroundImage: `url(${slide.imageSrc})`, backgroundSize: 'cover', backgroundPosition: 'center' }
: slide.bgColor?.startsWith('linear-gradient')
? { backgroundImage: slide.bgColor }
: { backgroundColor: slide.bgColor || '#3b82f6' };
return (
<div
key={i}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
opacity: i === activeIndex ? 1 : 0,
transition: 'opacity 0.5s ease-in-out',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
...bg,
}}
>
{(slide.heading || slide.text || slide.buttonText) && (
<div style={{ textAlign: 'center', padding: '20px', zIndex: 1 }}>
{slide.heading && (
<h2 style={{ fontSize: '36px', fontWeight: '700', color: '#ffffff', marginBottom: '12px', fontFamily: 'Inter, sans-serif', textShadow: '0 2px 8px rgba(0,0,0,0.3)' }}>
{slide.heading}
</h2>
)}
{slide.text && (
<p style={{ fontSize: '18px', color: 'rgba(255,255,255,0.9)', marginBottom: '20px', fontFamily: 'Inter, sans-serif', textShadow: '0 1px 4px rgba(0,0,0,0.3)' }}>
{slide.text}
</p>
)}
{slide.buttonText && (
<a
href={slide.buttonHref || '#'}
onClick={(e) => e.preventDefault()}
style={{
display: 'inline-block',
padding: '12px 28px',
background: '#ffffff',
color: '#18181b',
textDecoration: 'none',
borderRadius: '8px',
fontWeight: '600',
fontSize: '15px',
fontFamily: 'Inter, sans-serif',
}}
>
{slide.buttonText}
</a>
)}
</div>
)}
</div>
);
};
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
position: 'relative',
width: '100%',
height,
overflow: 'hidden',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
{items.map((slide, i) => renderSlide(slide, i))}
{showArrows && items.length > 1 && (
<>
<button onClick={goPrev} style={{ ...arrowStyle, left: '16px' }}>
<i className="fa fa-chevron-left" />
</button>
<button onClick={goNext} style={{ ...arrowStyle, right: '16px' }}>
<i className="fa fa-chevron-right" />
</button>
</>
)}
{showDots && items.length > 1 && (
<div style={{ position: 'absolute', bottom: '16px', left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: '8px', zIndex: 2 }}>
{items.map((_, i) => (
<button
key={i}
onClick={() => goTo(i)}
style={{
width: '10px',
height: '10px',
borderRadius: '50%',
border: 'none',
cursor: 'pointer',
backgroundColor: i === activeIndex ? '#ffffff' : 'rgba(255,255,255,0.5)',
transition: 'background-color 0.3s',
}}
/>
))}
</div>
)}
</section>
);
};
/* ---------- Settings panel ---------- */
const ContentSliderSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as ContentSliderProps,
}));
const items = props.slides || defaultSlides;
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,
};
const heightPresets = ['300px', '400px', '500px', '600px', '80vh'];
const intervalPresets = [3000, 4000, 5000, 7000, 10000];
const bgPresets = [
'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)',
'linear-gradient(135deg, #10b981 0%, #059669 100%)',
'linear-gradient(135deg, #f59e0b 0%, #ef4444 100%)',
'linear-gradient(135deg, #ec4899 0%, #8b5cf6 100%)',
'#18181b',
'#0f172a',
'#1e293b',
'#3b82f6',
];
const updateSlide = (index: number, field: keyof Slide, value: string) => {
setProp((p: ContentSliderProps) => {
const updated = [...(p.slides || defaultSlides)];
updated[index] = { ...updated[index], [field]: value };
p.slides = updated;
});
};
const addSlide = () => {
setProp((p: ContentSliderProps) => {
const current = p.slides || defaultSlides;
p.slides = [...current, {
type: 'image',
imageSrc: '',
heading: 'New Slide',
text: 'Add your content here',
buttonText: '',
buttonHref: '#',
bgColor: 'linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%)',
}];
});
};
const removeSlide = (index: number) => {
setProp((p: ContentSliderProps) => {
const updated = [...(p.slides || defaultSlides)];
updated.splice(index, 1);
p.slides = updated;
});
};
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Height */}
<div>
<label style={labelStyle}>Height</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{heightPresets.map((h) => (
<button
key={h}
onClick={() => setProp((p: ContentSliderProps) => { p.height = h; })}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.height === h ? '#3b82f6' : '#27272a',
color: props.height === h ? '#fff' : '#e4e4e7',
}}
>
{h}
</button>
))}
</div>
</div>
{/* Autoplay */}
<div>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
checked={props.autoplay !== false}
onChange={(e) => setProp((p: ContentSliderProps) => { p.autoplay = e.target.checked; })}
/>
Autoplay
</label>
</div>
{/* Interval */}
{props.autoplay !== false && (
<div>
<label style={labelStyle}>Interval (ms)</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{intervalPresets.map((ms) => (
<button
key={ms}
onClick={() => setProp((p: ContentSliderProps) => { p.interval = ms; })}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: (props.interval || 5000) === ms ? '#3b82f6' : '#27272a',
color: (props.interval || 5000) === ms ? '#fff' : '#e4e4e7',
}}
>
{ms / 1000}s
</button>
))}
</div>
</div>
)}
{/* Show Arrows */}
<div>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
checked={props.showArrows !== false}
onChange={(e) => setProp((p: ContentSliderProps) => { p.showArrows = e.target.checked; })}
/>
Show Arrows
</label>
</div>
{/* Show Dots */}
<div>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
checked={props.showDots !== false}
onChange={(e) => setProp((p: ContentSliderProps) => { p.showDots = e.target.checked; })}
/>
Show Dots
</label>
</div>
{/* Slides */}
<div>
<label style={labelStyle}>Slides</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((slide, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<span style={{ fontSize: 11, color: '#a1a1aa', flex: 'none', width: 18 }}>{i + 1}.</span>
<select
value={slide.type}
onChange={(e) => updateSlide(i, 'type', e.target.value)}
style={{ ...inputStyle, width: 70, flex: 'none', cursor: 'pointer' }}
>
<option value="image">Image</option>
<option value="content">Content</option>
</select>
<input type="text" value={slide.heading || ''} onChange={(e) => updateSlide(i, 'heading', e.target.value)} placeholder="Heading" style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removeSlide(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}
>
X
</button>
</div>
<input type="text" value={slide.imageSrc || ''} onChange={(e) => updateSlide(i, 'imageSrc', e.target.value)} placeholder="Image URL (optional)" style={inputStyle} />
<input type="text" value={slide.text || ''} onChange={(e) => updateSlide(i, 'text', e.target.value)} placeholder="Text" style={inputStyle} />
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={slide.buttonText || ''} onChange={(e) => updateSlide(i, 'buttonText', e.target.value)} placeholder="Button text" style={{ ...inputStyle, flex: 1 }} />
<input type="text" value={slide.buttonHref || ''} onChange={(e) => updateSlide(i, 'buttonHref', e.target.value)} placeholder="Button URL" style={{ ...inputStyle, flex: 1 }} />
</div>
<div>
<span style={{ fontSize: 10, color: '#a1a1aa', display: 'block', marginBottom: 2 }}>Background</span>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((bg) => (
<button
key={bg}
onClick={() => updateSlide(i, 'bgColor', bg)}
style={{
width: 22, height: 22, borderRadius: 4, border: '1px solid #3f3f46',
background: bg, cursor: 'pointer',
outline: slide.bgColor === bg ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
</div>
))}
</div>
<button
onClick={addSlide}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Slide
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
ContentSlider.craft = {
displayName: 'Content Slider',
props: {
slides: defaultSlides,
autoplay: true,
interval: 5000,
showDots: true,
showArrows: true,
height: '400px',
style: {},
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: ContentSliderSettings,
},
};
/* ---------- HTML export ---------- */
(ContentSlider as any).toHtml = (props: ContentSliderProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const {
slides = defaultSlides,
autoplay = true,
interval = 5000,
showDots = true,
showArrows = true,
height = '400px',
style = {},
} = props;
const items = slides.length > 0 ? slides : defaultSlides;
const uid = 'cs_' + Math.random().toString(36).slice(2, 8);
const sectionStyle = cssPropsToString({
position: 'relative',
width: '100%',
height,
overflow: 'hidden',
...style,
});
const slidesHtml = items.map((slide, i) => {
const hasBgImage = slide.imageSrc;
const bgStyle = hasBgImage
? `background-image:url(${esc(slide.imageSrc!)});background-size:cover;background-position:center`
: slide.bgColor?.startsWith('linear-gradient')
? `background-image:${slide.bgColor}`
: `background-color:${slide.bgColor || '#3b82f6'}`;
const contentParts: string[] = [];
if (slide.heading) {
contentParts.push(`<h2 style="font-size:36px;font-weight:700;color:#ffffff;margin-bottom:12px;font-family:Inter,sans-serif;text-shadow:0 2px 8px rgba(0,0,0,0.3)">${esc(slide.heading)}</h2>`);
}
if (slide.text) {
contentParts.push(`<p style="font-size:18px;color:rgba(255,255,255,0.9);margin-bottom:20px;font-family:Inter,sans-serif;text-shadow:0 1px 4px rgba(0,0,0,0.3)">${esc(slide.text)}</p>`);
}
if (slide.buttonText) {
contentParts.push(`<a href="${esc(slide.buttonHref || '#')}" style="display:inline-block;padding:12px 28px;background:#ffffff;color:#18181b;text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;font-family:Inter,sans-serif">${esc(slide.buttonText)}</a>`);
}
const innerHtml = contentParts.length > 0
? `<div style="text-align:center;padding:20px;z-index:1">${contentParts.join('\n ')}</div>`
: '';
return `<div id="${uid}_s${i}" style="position:absolute;top:0;left:0;width:100%;height:100%;opacity:${i === 0 ? 1 : 0};transition:opacity 0.5s ease-in-out;display:flex;flex-direction:column;align-items:center;justify-content:center;${bgStyle}">
${innerHtml}
</div>`;
}).join('\n ');
const arrowsHtml = showArrows && items.length > 1
? `<button onclick="${uid}_prev()" style="position:absolute;top:50%;left:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:#18181b;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-left"></i></button>
<button onclick="${uid}_next()" style="position:absolute;top:50%;right:16px;transform:translateY(-50%);width:40px;height:40px;border-radius:50%;border:none;background:rgba(255,255,255,0.9);color:#18181b;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:2;box-shadow:0 2px 8px rgba(0,0,0,0.15)"><i class="fa fa-chevron-right"></i></button>`
: '';
const dotsHtml = showDots && items.length > 1
? `<div style="position:absolute;bottom:16px;left:50%;transform:translateX(-50%);display:flex;gap:8px;z-index:2">
${items.map((_, i) => `<button onclick="${uid}_go(${i})" id="${uid}_d${i}" style="width:10px;height:10px;border-radius:50%;border:none;cursor:pointer;background-color:${i === 0 ? '#ffffff' : 'rgba(255,255,255,0.5)'};transition:background-color 0.3s"></button>`).join('\n ')}
</div>`
: '';
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
${slidesHtml}
${arrowsHtml}
${dotsHtml}
<script>
(function(){
var current=0, total=${items.length}, uid="${uid}";
function show(idx){
document.getElementById(uid+"_s"+current).style.opacity="0";
${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="rgba(255,255,255,0.5)";` : ''}
current=((idx%total)+total)%total;
document.getElementById(uid+"_s"+current).style.opacity="1";
${showDots ? `document.getElementById(uid+"_d"+current).style.backgroundColor="#ffffff";` : ''}
}
window["${uid}_go"]=show;
window["${uid}_next"]=function(){show(current+1);};
window["${uid}_prev"]=function(){show(current-1);};
${autoplay && items.length > 1 ? `setInterval(function(){show(current+1);},${interval});` : ''}
})();
</script>
</section>`,
};
};

View File

@@ -0,0 +1,311 @@
import React, { CSSProperties, useEffect, useState, useCallback } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface CountdownProps {
targetDate?: string;
heading?: string;
style?: CSSProperties;
digitColor?: string;
labelColor?: string;
bgColor?: string;
}
interface TimeLeft {
days: number;
hours: number;
minutes: number;
seconds: number;
}
function getDefaultTargetDate(): string {
const d = new Date();
d.setDate(d.getDate() + 30);
return d.toISOString().split('T')[0];
}
function calcTimeLeft(target: string): TimeLeft {
const diff = new Date(target).getTime() - Date.now();
if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0 };
return {
days: Math.floor(diff / (1000 * 60 * 60 * 24)),
hours: Math.floor((diff / (1000 * 60 * 60)) % 24),
minutes: Math.floor((diff / (1000 * 60)) % 60),
seconds: Math.floor((diff / 1000) % 60),
};
}
const DEFAULT_TARGET = getDefaultTargetDate();
export const Countdown: UserComponent<CountdownProps> = ({
targetDate = DEFAULT_TARGET,
heading = 'Coming Soon',
style = {},
digitColor = '#ffffff',
labelColor = 'rgba(255,255,255,0.7)',
bgColor = '#18181b',
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const [timeLeft, setTimeLeft] = useState<TimeLeft>(() => calcTimeLeft(targetDate));
useEffect(() => {
setTimeLeft(calcTimeLeft(targetDate));
const interval = setInterval(() => {
setTimeLeft(calcTimeLeft(targetDate));
}, 1000);
return () => clearInterval(interval);
}, [targetDate]);
const pad = (n: number) => String(n).padStart(2, '0');
const boxStyle: CSSProperties = {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '4px',
minWidth: '80px',
};
const digitStyle: CSSProperties = {
fontSize: '48px',
fontWeight: '700',
color: digitColor,
lineHeight: '1',
fontFamily: 'Inter, sans-serif',
};
const unitLabelStyle: CSSProperties = {
fontSize: '12px',
color: labelColor,
textTransform: 'uppercase',
letterSpacing: '0.1em',
fontFamily: 'Inter, sans-serif',
};
const units: Array<{ label: string; value: number }> = [
{ label: 'Days', value: timeLeft.days },
{ label: 'Hours', value: timeLeft.hours },
{ label: 'Minutes', value: timeLeft.minutes },
{ label: 'Seconds', value: timeLeft.seconds },
];
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
padding: '60px 20px',
textAlign: 'center',
backgroundColor: bgColor,
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
{heading && (
<h2 style={{ fontSize: '32px', fontWeight: '700', color: digitColor, marginBottom: '32px', fontFamily: 'Inter, sans-serif' }}>
{heading}
</h2>
)}
<div style={{ display: 'flex', justifyContent: 'center', gap: '24px', flexWrap: 'wrap' }}>
{units.map((u) => (
<div key={u.label} style={boxStyle}>
<span style={digitStyle}>{pad(u.value)}</span>
<span style={unitLabelStyle}>{u.label}</span>
</div>
))}
</div>
</section>
);
};
/* ---------- Settings panel ---------- */
const CountdownSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as CountdownProps,
}));
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
const inputStyle: CSSProperties = {
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
};
const colorPresets = ['#ffffff', '#f8fafc', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
const bgPresets = ['#18181b', '#0f172a', '#1e293b', '#1e1b4b', '#042f2e', '#27272a', '#ffffff', '#f8fafc'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Target date */}
<div>
<label style={labelStyle}>Target Date</label>
<input
type="date"
value={props.targetDate || DEFAULT_TARGET}
onChange={(e) => setProp((p: CountdownProps) => { p.targetDate = e.target.value; })}
style={inputStyle}
/>
</div>
{/* Heading */}
<div>
<label style={labelStyle}>Heading</label>
<input
type="text"
value={props.heading || ''}
onChange={(e) => setProp((p: CountdownProps) => { p.heading = e.target.value; })}
placeholder="Coming Soon"
style={inputStyle}
/>
</div>
{/* Digit color */}
<div>
<label style={labelStyle}>Digit Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: CountdownProps) => { p.digitColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.digitColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Label color */}
<div>
<label style={labelStyle}>Label Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: CountdownProps) => { p.labelColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.labelColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Background color */}
<div>
<label style={labelStyle}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: CountdownProps) => { 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>
);
};
/* ---------- Craft config ---------- */
Countdown.craft = {
displayName: 'Countdown',
props: {
targetDate: DEFAULT_TARGET,
heading: 'Coming Soon',
style: {},
digitColor: '#ffffff',
labelColor: 'rgba(255,255,255,0.7)',
bgColor: '#18181b',
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: CountdownSettings,
},
};
/* ---------- HTML export ---------- */
(Countdown as any).toHtml = (props: CountdownProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const {
targetDate = DEFAULT_TARGET,
heading = 'Coming Soon',
style = {},
digitColor = '#ffffff',
labelColor = 'rgba(255,255,255,0.7)',
bgColor = '#18181b',
} = props;
const sectionStyle = cssPropsToString({
padding: '60px 20px',
textAlign: 'center',
backgroundColor: bgColor,
...style,
});
const headingHtml = heading
? `<h2 style="font-size:32px;font-weight:700;color:${digitColor};margin-bottom:32px;font-family:Inter,sans-serif">${esc(heading)}</h2>`
: '';
const boxStyle = 'display:flex;flex-direction:column;align-items:center;gap:4px;min-width:80px';
const dStyle = `font-size:48px;font-weight:700;color:${digitColor};line-height:1;font-family:Inter,sans-serif`;
const lStyle = `font-size:12px;color:${labelColor};text-transform:uppercase;letter-spacing:0.1em;font-family:Inter,sans-serif`;
// Generate a unique ID for this countdown instance
const uid = 'cd_' + Math.random().toString(36).slice(2, 8);
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
${headingHtml}
<div style="display:flex;justify-content:center;gap:24px;flex-wrap:wrap">
<div style="${boxStyle}"><span id="${uid}_d" style="${dStyle}">00</span><span style="${lStyle}">Days</span></div>
<div style="${boxStyle}"><span id="${uid}_h" style="${dStyle}">00</span><span style="${lStyle}">Hours</span></div>
<div style="${boxStyle}"><span id="${uid}_m" style="${dStyle}">00</span><span style="${lStyle}">Minutes</span></div>
<div style="${boxStyle}"><span id="${uid}_s" style="${dStyle}">00</span><span style="${lStyle}">Seconds</span></div>
</div>
<script>
(function(){
var target = new Date("${targetDate}").getTime();
function pad(n){ return String(n).padStart(2,'0'); }
function update(){
var diff = target - Date.now();
if(diff<=0){ diff=0; }
var d = Math.floor(diff/(1000*60*60*24));
var h = Math.floor((diff/(1000*60*60))%24);
var m = Math.floor((diff/(1000*60))%60);
var s = Math.floor((diff/1000)%60);
document.getElementById("${uid}_d").textContent = pad(d);
document.getElementById("${uid}_h").textContent = pad(h);
document.getElementById("${uid}_m").textContent = pad(m);
document.getElementById("${uid}_s").textContent = pad(s);
}
update();
setInterval(update,1000);
})();
</script>
</section>`,
};
};

View File

@@ -0,0 +1,200 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface FeatureItem {
title: string;
description: string;
icon: string;
}
interface FeaturesGridProps {
features?: FeatureItem[];
style?: CSSProperties;
}
const defaultFeatures: FeatureItem[] = [
{ title: 'Fast & Reliable', description: 'Built for performance with optimized loading and rock-solid uptime.', icon: '⚡' },
{ title: 'Easy to Use', description: 'Intuitive drag-and-drop interface that anyone can master in minutes.', icon: '✨' },
{ title: 'Fully Responsive', description: 'Looks great on every device, from phones to ultrawide monitors.', icon: '📱' },
];
export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
features = defaultFeatures,
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
padding: '80px 20px',
backgroundColor: '#ffffff',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
<div style={{ maxWidth: '1100px', margin: '0 auto', display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '32px' }}>
{(Array.isArray(features) ? features : []).map((feat, i) => (
<div
key={i}
style={{
textAlign: 'center',
padding: '32px 24px',
borderRadius: '12px',
backgroundColor: '#f8fafc',
border: '1px solid #e2e8f0',
}}
>
<div style={{ fontSize: '36px', marginBottom: '16px' }}>{feat.icon}</div>
<h3 style={{ fontSize: '20px', fontWeight: '600', color: '#18181b', marginBottom: '8px' }}>{feat.title}</h3>
<p style={{ fontSize: '14px', color: '#64748b', lineHeight: '1.6' }}>{feat.description}</p>
</div>
))}
</div>
</section>
);
};
/* ---------- Settings panel ---------- */
const FeaturesGridSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as FeaturesGridProps,
}));
const features = props.features || defaultFeatures;
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const updateFeature = (index: number, field: keyof FeatureItem, value: string) => {
setProp((p: FeaturesGridProps) => {
const updated = [...(p.features || defaultFeatures)];
updated[index] = { ...updated[index], [field]: value };
p.features = updated;
});
};
const addFeature = () => {
setProp((p: FeaturesGridProps) => {
p.features = [...(p.features || defaultFeatures), { title: 'New Feature', description: 'Describe this feature.', icon: '🔧' }];
});
};
const removeFeature = (index: number) => {
setProp((p: FeaturesGridProps) => {
const updated = [...(p.features || defaultFeatures)];
updated.splice(index, 1);
p.features = updated;
});
};
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: FeaturesGridProps) => { 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 }}>Features</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{(Array.isArray(features) ? features : []).map((feat, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={feat.icon} onChange={(e) => updateFeature(i, 'icon', e.target.value)} placeholder="Icon" style={{ ...inputStyle, width: 40, flex: 'none', textAlign: 'center' }} />
<input type="text" value={feat.title} onChange={(e) => updateFeature(i, 'title', e.target.value)} placeholder="Title" style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removeFeature(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
X
</button>
</div>
<textarea
value={feat.description}
onChange={(e) => updateFeature(i, 'description', e.target.value)}
placeholder="Description"
rows={2}
style={{ ...inputStyle, resize: 'vertical' }}
/>
</div>
))}
</div>
<button
onClick={addFeature}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Feature
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
FeaturesGrid.craft = {
displayName: 'Features Grid',
props: {
features: defaultFeatures,
style: { backgroundColor: '#ffffff' },
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: FeaturesGridSettings,
},
};
/* ---------- HTML export ---------- */
(FeaturesGrid as any).toHtml = (props: FeaturesGridProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const sectionStyle = cssPropsToString({
padding: '80px 20px',
...props.style,
});
const cards = (props.features || defaultFeatures).map((feat) => {
return `<div style="text-align:center;padding:32px 24px;border-radius:12px;background-color:#f8fafc;border:1px solid #e2e8f0">
<div style="font-size:36px;margin-bottom:16px">${esc(feat.icon)}</div>
<h3 style="font-size:20px;font-weight:600;color:#18181b;margin-bottom:8px">${esc(feat.title)}</h3>
<p style="font-size:14px;color:#64748b;line-height:1.6">${esc(feat.description)}</p>
</div>`;
}).join('\n ');
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(3,1fr);gap:32px">
${cards}
</div>
</section>`,
};
};

View File

@@ -0,0 +1,322 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface GalleryImage {
src: string;
alt: string;
caption?: string;
}
interface GalleryProps {
images?: GalleryImage[];
columns?: number;
gap?: string;
style?: CSSProperties;
lightbox?: boolean;
}
const placeholderSvg = (index: number) => {
const colors = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#ec4899'];
const color = colors[index % colors.length];
return `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="300" viewBox="0 0 400 300"><rect fill="${color}" width="400" height="300" opacity="0.15"/><rect fill="${color}" x="150" y="100" width="100" height="100" rx="12" opacity="0.3"/><text x="200" y="160" text-anchor="middle" font-family="sans-serif" font-size="24" fill="${color}" opacity="0.6">${index + 1}</text></svg>`)}`;
};
const defaultImages: GalleryImage[] = [
{ src: placeholderSvg(0), alt: 'Gallery image 1', caption: 'First image' },
{ src: placeholderSvg(1), alt: 'Gallery image 2', caption: 'Second image' },
{ src: placeholderSvg(2), alt: 'Gallery image 3', caption: 'Third image' },
{ src: placeholderSvg(3), alt: 'Gallery image 4', caption: 'Fourth image' },
{ src: placeholderSvg(4), alt: 'Gallery image 5', caption: 'Fifth image' },
{ src: placeholderSvg(5), alt: 'Gallery image 6', caption: 'Sixth image' },
];
export const Gallery: UserComponent<GalleryProps> = ({
images = defaultImages,
columns = 3,
gap = '16px',
style = {},
lightbox = false,
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
padding: '60px 20px',
backgroundColor: '#ffffff',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
<div
style={{
maxWidth: '1100px',
margin: '0 auto',
display: 'grid',
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap: gap,
}}
>
{images.map((img, i) => (
<div key={i} style={{ position: 'relative', overflow: 'hidden', borderRadius: '8px' }}>
<img
src={img.src}
alt={img.alt}
style={{
width: '100%',
height: '200px',
objectFit: 'cover',
display: 'block',
borderRadius: '8px',
backgroundColor: '#f1f5f9',
}}
/>
{img.caption && (
<div
style={{
position: 'absolute',
bottom: '0',
left: '0',
right: '0',
padding: '8px 12px',
background: 'linear-gradient(transparent, rgba(0,0,0,0.7))',
color: '#ffffff',
fontSize: '12px',
borderBottomLeftRadius: '8px',
borderBottomRightRadius: '8px',
}}
>
{img.caption}
</div>
)}
</div>
))}
</div>
</section>
);
};
/* ---------- Settings panel ---------- */
const GallerySettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as GalleryProps,
}));
const images = props.images || defaultImages;
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
const updateImage = (index: number, field: keyof GalleryImage, value: string) => {
setProp((p: GalleryProps) => {
const updated = [...(p.images || defaultImages)];
updated[index] = { ...updated[index], [field]: value };
p.images = updated;
});
};
const addImage = () => {
setProp((p: GalleryProps) => {
const current = p.images || defaultImages;
p.images = [...current, { src: placeholderSvg(current.length), alt: `Gallery image ${current.length + 1}`, caption: '' }];
});
};
const removeImage = (index: number) => {
setProp((p: GalleryProps) => {
const updated = [...(p.images || defaultImages)];
updated.splice(index, 1);
p.images = updated;
});
};
const columnOptions = [2, 3, 4, 5, 6];
const gapOptions = ['8px', '12px', '16px', '24px', '32px'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={labelStyle}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'].map((c) => (
<button
key={c}
onClick={() => setProp((p: GalleryProps) => { 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={labelStyle}>Columns</label>
<div style={{ display: 'flex', gap: 4 }}>
{columnOptions.map((n) => (
<button
key={n}
onClick={() => setProp((p: GalleryProps) => { p.columns = n; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.columns === n ? '#3b82f6' : '#27272a',
color: props.columns === n ? '#fff' : '#e4e4e7',
}}
>
{n}
</button>
))}
</div>
</div>
<div>
<label style={labelStyle}>Gap</label>
<div style={{ display: 'flex', gap: 4 }}>
{gapOptions.map((g) => (
<button
key={g}
onClick={() => setProp((p: GalleryProps) => { p.gap = g; })}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.gap === g ? '#3b82f6' : '#27272a',
color: props.gap === g ? '#fff' : '#e4e4e7',
}}
>
{g}
</button>
))}
</div>
</div>
<div>
<label style={{ ...labelStyle, display: 'flex', alignItems: 'center', gap: 6 }}>
<input
type="checkbox"
checked={props.lightbox || false}
onChange={(e) => setProp((p: GalleryProps) => { p.lightbox = e.target.checked; })}
/>
Lightbox (click to enlarge in exported HTML)
</label>
</div>
<div>
<label style={labelStyle}>Images</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{images.map((img, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<img
src={img.src}
alt=""
style={{ width: 32, height: 32, objectFit: 'cover', borderRadius: 4, flexShrink: 0, backgroundColor: '#27272a' }}
/>
<input type="text" value={img.src} onChange={(e) => updateImage(i, 'src', e.target.value)} placeholder="Image URL" style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removeImage(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
X
</button>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={img.alt} onChange={(e) => updateImage(i, 'alt', e.target.value)} placeholder="Alt text" style={{ ...inputStyle, flex: 1 }} />
<input type="text" value={img.caption || ''} onChange={(e) => updateImage(i, 'caption', e.target.value)} placeholder="Caption" style={{ ...inputStyle, flex: 1 }} />
</div>
</div>
))}
</div>
<button
onClick={addImage}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Image
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Gallery.craft = {
displayName: 'Gallery',
props: {
images: defaultImages,
columns: 3,
gap: '16px',
style: { backgroundColor: '#ffffff' },
lightbox: false,
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: GallerySettings,
},
};
/* ---------- HTML export ---------- */
(Gallery as any).toHtml = (props: GalleryProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const sectionStyle = cssPropsToString({
padding: '60px 20px',
...props.style,
});
const images = props.images || defaultImages;
const columns = props.columns || 3;
const gap = props.gap || '16px';
const lightbox = props.lightbox || false;
const galleryId = 'gallery_' + Math.random().toString(36).slice(2, 8);
const items = images.map((img) => {
const caption = img.caption
? `<div style="position:absolute;bottom:0;left:0;right:0;padding:8px 12px;background:linear-gradient(transparent,rgba(0,0,0,0.7));color:#ffffff;font-size:12px;border-bottom-left-radius:8px;border-bottom-right-radius:8px">${esc(img.caption)}</div>`
: '';
const clickAttr = lightbox ? ` onclick="${galleryId}_open('${esc(img.src)}')" style="cursor:pointer;position:relative;overflow:hidden;border-radius:8px"` : ' style="position:relative;overflow:hidden;border-radius:8px"';
return `<div${clickAttr}>
<img src="${esc(img.src)}" alt="${esc(img.alt)}" style="width:100%;height:200px;object-fit:cover;display:block;border-radius:8px;background-color:#f1f5f9" />
${caption}
</div>`;
}).join('\n ');
let lightboxHtml = '';
if (lightbox) {
lightboxHtml = `
<div id="${galleryId}_overlay" onclick="${galleryId}_close()" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);z-index:9999;justify-content:center;align-items:center;cursor:pointer">
<img id="${galleryId}_img" src="" alt="" style="max-width:90%;max-height:90%;object-fit:contain;border-radius:8px" />
</div>
<script>
function ${galleryId}_open(src){var o=document.getElementById('${galleryId}_overlay');document.getElementById('${galleryId}_img').src=src;o.style.display='flex';}
function ${galleryId}_close(){document.getElementById('${galleryId}_overlay').style.display='none';}
</script>`;
}
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:${gap}">
${items}
</div>${lightboxHtml}
</section>`,
};
};

View File

@@ -0,0 +1,457 @@
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: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
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>`,
};
};

View File

@@ -0,0 +1,368 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface Counter {
number: number;
suffix: string;
label: string;
}
interface NumberCounterProps {
counters?: Counter[];
columns?: number;
numberColor?: string;
labelColor?: string;
numberSize?: string;
style?: CSSProperties;
}
const defaultCounters: Counter[] = [
{ number: 150, suffix: '+', label: 'Projects' },
{ number: 50, suffix: '+', label: 'Clients' },
{ number: 10, suffix: '', label: 'Years' },
{ number: 99, suffix: '%', label: 'Satisfaction' },
];
export const NumberCounter: UserComponent<NumberCounterProps> = ({
counters = defaultCounters,
columns = 4,
numberColor = '#3b82f6',
labelColor = '#6b7280',
numberSize = '48px',
style = {},
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const items = counters.length > 0 ? counters : defaultCounters;
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
padding: '60px 20px',
backgroundColor: '#ffffff',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
<div
style={{
maxWidth: '1100px',
margin: '0 auto',
display: 'grid',
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap: '32px',
textAlign: 'center',
}}
>
{items.map((counter, i) => (
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
<span style={{
fontSize: numberSize,
fontWeight: '700',
color: numberColor,
lineHeight: '1.1',
fontFamily: 'Inter, sans-serif',
}}>
{counter.number}{counter.suffix}
</span>
<span style={{
fontSize: '15px',
color: labelColor,
fontFamily: 'Inter, sans-serif',
fontWeight: '500',
}}>
{counter.label}
</span>
</div>
))}
</div>
</section>
);
};
/* ---------- Settings panel ---------- */
const NumberCounterSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as NumberCounterProps,
}));
const items = props.counters || defaultCounters;
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,
};
const columnOptions = [2, 3, 4, 5, 6];
const sizePresets = ['32px', '40px', '48px', '56px', '64px'];
const numberColorPresets = ['#3b82f6', '#10b981', '#8b5cf6', '#ef4444', '#f59e0b', '#18181b', '#ec4899', '#0ea5e9'];
const labelColorPresets = ['#6b7280', '#374151', '#9ca3af', '#a1a1aa', '#64748b', '#18181b'];
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'];
const updateCounter = (index: number, field: keyof Counter, value: string | number) => {
setProp((p: NumberCounterProps) => {
const updated = [...(p.counters || defaultCounters)];
updated[index] = { ...updated[index], [field]: value };
p.counters = updated;
});
};
const addCounter = () => {
setProp((p: NumberCounterProps) => {
p.counters = [...(p.counters || defaultCounters), { number: 100, suffix: '+', label: 'New Stat' }];
});
};
const removeCounter = (index: number) => {
setProp((p: NumberCounterProps) => {
const updated = [...(p.counters || defaultCounters)];
updated.splice(index, 1);
p.counters = updated;
});
};
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Columns */}
<div>
<label style={labelStyle}>Columns</label>
<div style={{ display: 'flex', gap: 4 }}>
{columnOptions.map((n) => (
<button
key={n}
onClick={() => setProp((p: NumberCounterProps) => { p.columns = n; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: (props.columns || 4) === n ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{n}
</button>
))}
</div>
</div>
{/* Number Size */}
<div>
<label style={labelStyle}>Number Size</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{sizePresets.map((s) => (
<button
key={s}
onClick={() => setProp((p: NumberCounterProps) => { p.numberSize = s; })}
style={{
padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: (props.numberSize || '48px') === s ? '#3b82f6' : '#27272a',
color: (props.numberSize || '48px') === s ? '#fff' : '#e4e4e7',
}}
>
{s}
</button>
))}
</div>
</div>
{/* Number Color */}
<div>
<label style={labelStyle}>Number Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{numberColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: NumberCounterProps) => { p.numberColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: (props.numberColor || '#3b82f6') === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Label Color */}
<div>
<label style={labelStyle}>Label Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{labelColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: NumberCounterProps) => { p.labelColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: (props.labelColor || '#6b7280') === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Background */}
<div>
<label style={labelStyle}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: NumberCounterProps) => { 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>
{/* Counters */}
<div>
<label style={labelStyle}>Counters</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((counter, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input
type="number"
value={counter.number}
onChange={(e) => updateCounter(i, 'number', parseInt(e.target.value) || 0)}
placeholder="Number"
style={{ ...inputStyle, width: 70, flex: 'none' }}
/>
<input
type="text"
value={counter.suffix}
onChange={(e) => updateCounter(i, 'suffix', e.target.value)}
placeholder="Suffix"
style={{ ...inputStyle, width: 40, flex: 'none' }}
/>
<input
type="text"
value={counter.label}
onChange={(e) => updateCounter(i, 'label', e.target.value)}
placeholder="Label"
style={{ ...inputStyle, flex: 1 }}
/>
<button
onClick={() => removeCounter(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}
>
X
</button>
</div>
</div>
))}
</div>
<button
onClick={addCounter}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Counter
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
NumberCounter.craft = {
displayName: 'Number Counter',
props: {
counters: defaultCounters,
columns: 4,
numberColor: '#3b82f6',
labelColor: '#6b7280',
numberSize: '48px',
style: { backgroundColor: '#ffffff' },
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: NumberCounterSettings,
},
};
/* ---------- HTML export ---------- */
(NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const {
counters = defaultCounters,
columns = 4,
numberColor = '#3b82f6',
labelColor = '#6b7280',
numberSize = '48px',
style = {},
} = props;
const items = counters.length > 0 ? counters : defaultCounters;
const uid = 'nc_' + Math.random().toString(36).slice(2, 8);
const sectionStyle = cssPropsToString({
padding: '60px 20px',
backgroundColor: '#ffffff',
...style,
});
const countersHtml = items.map((counter, i) => {
return `<div style="display:flex;flex-direction:column;align-items:center;gap:8px">
<span id="${uid}_n${i}" data-target="${counter.number}" data-suffix="${esc(counter.suffix)}" style="font-size:${numberSize};font-weight:700;color:${numberColor};line-height:1.1;font-family:Inter,sans-serif">0${esc(counter.suffix)}</span>
<span style="font-size:15px;color:${labelColor};font-family:Inter,sans-serif;font-weight:500">${esc(counter.label)}</span>
</div>`;
}).join('\n ');
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div id="${uid}" style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:32px;text-align:center">
${countersHtml}
</div>
<script>
(function(){
var uid="${uid}",started=false;
function animate(){
if(started)return;started=true;
for(var i=0;i<${items.length};i++){
(function(el){
var target=parseInt(el.getAttribute("data-target")),
suffix=el.getAttribute("data-suffix")||"",
current=0,
step=Math.max(1,Math.floor(target/60)),
timer=setInterval(function(){
current+=step;
if(current>=target){current=target;clearInterval(timer);}
el.textContent=current+suffix;
},16);
})(document.getElementById(uid+"_n"+i));
}
}
if("IntersectionObserver"in window){
var obs=new IntersectionObserver(function(entries){
entries.forEach(function(e){if(e.isIntersecting){animate();obs.disconnect();}});
},{threshold:0.2});
obs.observe(document.getElementById(uid));
}else{animate();}
})();
</script>
</section>`,
};
};

View File

@@ -0,0 +1,451 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface PricingPlan {
name: string;
price: string;
period: string;
features: string[];
buttonText: string;
buttonHref: string;
isFeatured: boolean;
}
interface PricingTableProps {
plans?: PricingPlan[];
style?: CSSProperties;
featuredBg?: string;
bulletType?: string;
}
const bulletChars: Record<string, string> = {
check: '✓', dot: '●', arrow: '→', star: '★', dash: '—', none: '',
};
const defaultPlans: PricingPlan[] = [
{
name: 'Basic',
price: '$9',
period: '/month',
features: ['1 Website', '10 GB Storage', 'Free SSL Certificate', 'Email Support'],
buttonText: 'Get Started',
buttonHref: '#',
isFeatured: false,
},
{
name: 'Pro',
price: '$29',
period: '/month',
features: ['10 Websites', '100 GB Storage', 'Free SSL Certificate', 'Priority Support', 'Custom Domain', 'Analytics Dashboard'],
buttonText: 'Get Started',
buttonHref: '#',
isFeatured: true,
},
{
name: 'Enterprise',
price: '$99',
period: '/month',
features: ['Unlimited Websites', '1 TB Storage', 'Free SSL Certificate', '24/7 Phone Support', 'Custom Domain', 'Advanced Analytics', 'API Access', 'Team Collaboration'],
buttonText: 'Contact Sales',
buttonHref: '#',
isFeatured: false,
},
];
export const PricingTable: UserComponent<PricingTableProps> = ({
plans = defaultPlans,
style = {},
featuredBg = '#3b82f6',
bulletType = 'check',
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
padding: '80px 20px',
backgroundColor: '#ffffff',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
<div style={{
maxWidth: '1100px',
margin: '0 auto',
display: 'flex',
gap: '24px',
justifyContent: 'center',
alignItems: 'stretch',
flexWrap: 'wrap',
}}>
{plans.map((plan, i) => (
<div
key={i}
style={{
flex: '1 1 280px',
maxWidth: '360px',
backgroundColor: plan.isFeatured ? featuredBg : '#ffffff',
border: plan.isFeatured ? 'none' : '1px solid #e2e8f0',
borderRadius: '16px',
padding: '40px 32px',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
position: 'relative',
transform: plan.isFeatured ? 'scale(1.05)' : 'none',
boxShadow: plan.isFeatured ? '0 20px 60px rgba(59,130,246,0.3)' : '0 1px 3px rgba(0,0,0,0.06)',
}}
>
{plan.isFeatured && (
<div style={{
position: 'absolute',
top: '-12px',
backgroundColor: '#facc15',
color: '#18181b',
padding: '4px 16px',
borderRadius: '9999px',
fontSize: '12px',
fontWeight: '700',
textTransform: 'uppercase',
letterSpacing: '0.5px',
}}>
Most Popular
</div>
)}
<h3 style={{
fontSize: '20px',
fontWeight: '600',
color: plan.isFeatured ? '#ffffff' : '#18181b',
marginBottom: '8px',
marginTop: plan.isFeatured ? '8px' : '0',
}}>
{plan.name}
</h3>
<div style={{ marginBottom: '24px' }}>
<span style={{
fontSize: '48px',
fontWeight: '700',
color: plan.isFeatured ? '#ffffff' : '#18181b',
lineHeight: '1',
}}>
{plan.price}
</span>
<span style={{
fontSize: '16px',
color: plan.isFeatured ? 'rgba(255,255,255,0.8)' : '#64748b',
}}>
{plan.period}
</span>
</div>
<ul style={{
listStyle: 'none',
padding: '0',
margin: '0 0 32px 0',
width: '100%',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}>
{(Array.isArray(plan.features) ? plan.features : []).map((feature, fi) => (
<li key={fi} style={{
fontSize: '14px',
color: plan.isFeatured ? 'rgba(255,255,255,0.9)' : '#4b5563',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
<span style={{ color: plan.isFeatured ? '#bbf7d0' : '#10b981', fontWeight: '700' }}>{bulletChars[bulletType] || '✓'}</span>
{feature}
</li>
))}
</ul>
<a
href={plan.buttonHref}
onClick={(e) => e.preventDefault()}
style={{
marginTop: 'auto',
display: 'inline-block',
padding: '14px 32px',
backgroundColor: plan.isFeatured ? '#ffffff' : featuredBg,
color: plan.isFeatured ? featuredBg : '#ffffff',
textDecoration: 'none',
borderRadius: '8px',
fontWeight: '600',
fontSize: '14px',
width: '100%',
textAlign: 'center',
}}
>
{plan.buttonText}
</a>
</div>
))}
</div>
</section>
);
};
/* ---------- Settings panel ---------- */
const PricingTableSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as PricingTableProps,
}));
const plans = props.plans || defaultPlans;
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
const updatePlan = (index: number, field: keyof PricingPlan, value: any) => {
setProp((p: PricingTableProps) => {
const updated = [...(p.plans || defaultPlans)];
updated[index] = { ...updated[index], [field]: value };
p.plans = updated;
});
};
const updateFeature = (planIndex: number, featureIndex: number, value: string) => {
setProp((p: PricingTableProps) => {
const updated = [...(p.plans || defaultPlans)];
const features = [...(Array.isArray(updated[planIndex].features) ? updated[planIndex].features : [])];
features[featureIndex] = value;
updated[planIndex] = { ...updated[planIndex], features };
p.plans = updated;
});
};
const addFeature = (planIndex: number) => {
setProp((p: PricingTableProps) => {
const updated = [...(p.plans || defaultPlans)];
updated[planIndex] = { ...updated[planIndex], features: [...updated[planIndex].features, 'New Feature'] };
p.plans = updated;
});
};
const removeFeature = (planIndex: number, featureIndex: number) => {
setProp((p: PricingTableProps) => {
const updated = [...(p.plans || defaultPlans)];
const features = [...(Array.isArray(updated[planIndex].features) ? updated[planIndex].features : [])];
features.splice(featureIndex, 1);
updated[planIndex] = { ...updated[planIndex], features };
p.plans = updated;
});
};
const addPlan = () => {
setProp((p: PricingTableProps) => {
p.plans = [...(p.plans || defaultPlans), {
name: 'New Plan',
price: '$19',
period: '/month',
features: ['Feature 1', 'Feature 2'],
buttonText: 'Get Started',
buttonHref: '#',
isFeatured: false,
}];
});
};
const removePlan = (index: number) => {
setProp((p: PricingTableProps) => {
const updated = [...(p.plans || defaultPlans)];
updated.splice(index, 1);
p.plans = updated;
});
};
const bgPresets = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#18181b', '#0f172a', '#7c3aed'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={labelStyle}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'].map((c) => (
<button
key={c}
onClick={() => setProp((p: PricingTableProps) => { 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={labelStyle}>Featured Plan Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: PricingTableProps) => { p.featuredBg = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.featuredBg === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Plans</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{plans.map((plan, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={plan.name} onChange={(e) => updatePlan(i, 'name', e.target.value)} placeholder="Plan Name" style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removePlan(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
X
</button>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={plan.price} onChange={(e) => updatePlan(i, 'price', e.target.value)} placeholder="$29" style={{ ...inputStyle, width: '60px', flex: 'none' }} />
<input type="text" value={plan.period} onChange={(e) => updatePlan(i, 'period', e.target.value)} placeholder="/month" style={{ ...inputStyle, width: '70px', flex: 'none' }} />
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#a1a1aa', marginLeft: 'auto' }}>
<input
type="checkbox"
checked={plan.isFeatured}
onChange={(e) => updatePlan(i, 'isFeatured', e.target.checked)}
/>
Featured
</label>
</div>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={plan.buttonText} onChange={(e) => updatePlan(i, 'buttonText', e.target.value)} placeholder="Button Text" style={{ ...inputStyle, flex: 1 }} />
<input type="text" value={plan.buttonHref} onChange={(e) => updatePlan(i, 'buttonHref', e.target.value)} placeholder="URL" style={{ ...inputStyle, flex: 1 }} />
</div>
{/* Features */}
<div style={{ marginTop: 4 }}>
<span style={{ fontSize: 10, color: '#71717a' }}>Features:</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, marginTop: 2 }}>
{(Array.isArray(plan.features) ? plan.features : []).map((feat, fi) => (
<div key={fi} style={{ display: 'flex', gap: 2 }}>
<input type="text" value={feat} onChange={(e) => updateFeature(i, fi, e.target.value)} style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removeFeature(i, fi)}
style={{ padding: '1px 4px', fontSize: 10, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 3, cursor: 'pointer' }}
>
X
</button>
</div>
))}
</div>
<button
onClick={() => addFeature(i)}
style={{ marginTop: 2, width: '100%', padding: '3px', fontSize: 10, background: '#27272a', color: '#a1a1aa', border: '1px solid #3f3f46', borderRadius: 3, cursor: 'pointer' }}
>
+ Feature
</button>
</div>
</div>
))}
</div>
<button
onClick={addPlan}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Plan
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
PricingTable.craft = {
displayName: 'Pricing Table',
props: {
plans: defaultPlans,
style: { backgroundColor: '#ffffff' },
featuredBg: '#3b82f6',
bulletType: 'check',
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: PricingTableSettings,
},
};
/* ---------- HTML export ---------- */
(PricingTable as any).toHtml = (props: PricingTableProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const bulletType = props.bulletType || 'check';
const sectionStyle = cssPropsToString({
padding: '80px 20px',
...props.style,
});
const plans = props.plans || defaultPlans;
const featuredBg = props.featuredBg || '#3b82f6';
const cards = plans.map((plan) => {
const cardBg = plan.isFeatured ? featuredBg : '#ffffff';
const cardBorder = plan.isFeatured ? 'border:none;' : 'border:1px solid #e2e8f0;';
const textColor = plan.isFeatured ? '#ffffff' : '#18181b';
const subColor = plan.isFeatured ? 'rgba(255,255,255,0.8)' : '#64748b';
const featColor = plan.isFeatured ? 'rgba(255,255,255,0.9)' : '#4b5563';
const checkColor = plan.isFeatured ? '#bbf7d0' : '#10b981';
const btnBg = plan.isFeatured ? '#ffffff' : featuredBg;
const btnColor = plan.isFeatured ? featuredBg : '#ffffff';
const scale = plan.isFeatured ? 'transform:scale(1.05);' : '';
const shadow = plan.isFeatured ? 'box-shadow:0 20px 60px rgba(59,130,246,0.3);' : 'box-shadow:0 1px 3px rgba(0,0,0,0.06);';
const featuresHtml = (Array.isArray(plan.features) ? plan.features : []).map((f) =>
`<li style="font-size:14px;color:${featColor};display:flex;align-items:center;gap:8px"><span style="color:${checkColor};font-weight:700">${bulletChars[bulletType] || '✓'}</span>${esc(f)}</li>`
).join('\n ');
const badge = plan.isFeatured
? `<div style="position:absolute;top:-12px;background-color:#facc15;color:#18181b;padding:4px 16px;border-radius:9999px;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:0.5px">Most Popular</div>`
: '';
return `<div style="flex:1 1 280px;max-width:360px;background-color:${cardBg};${cardBorder}border-radius:16px;padding:40px 32px;display:flex;flex-direction:column;align-items:center;text-align:center;position:relative;${scale}${shadow}">
${badge}
<h3 style="font-size:20px;font-weight:600;color:${textColor};margin-bottom:8px;${plan.isFeatured ? 'margin-top:8px;' : ''}">${esc(plan.name)}</h3>
<div style="margin-bottom:24px">
<span style="font-size:48px;font-weight:700;color:${textColor};line-height:1">${esc(plan.price)}</span>
<span style="font-size:16px;color:${subColor}">${esc(plan.period)}</span>
</div>
<ul style="list-style:none;padding:0;margin:0 0 32px 0;width:100%;display:flex;flex-direction:column;gap:12px">
${featuresHtml}
</ul>
<a href="${plan.buttonHref || '#'}" style="margin-top:auto;display:inline-block;padding:14px 32px;background-color:${btnBg};color:${btnColor};text-decoration:none;border-radius:8px;font-weight:600;font-size:14px;width:100%;text-align:center">${esc(plan.buttonText)}</a>
</div>`;
}).join('\n ');
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:1100px;margin:0 auto;display:flex;gap:24px;justify-content:center;align-items:stretch;flex-wrap:wrap">
${cards}
</div>
</section>`,
};
};

View File

@@ -0,0 +1,339 @@
import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface TabItem {
label: string;
content: string;
}
interface TabsProps {
tabs?: TabItem[];
style?: CSSProperties;
activeTabBg?: string;
activeTabColor?: string;
inactiveTabBg?: string;
inactiveTabColor?: string;
contentBg?: string;
}
const defaultTabs: TabItem[] = [
{ label: 'Overview', content: 'Welcome to our platform. We provide the tools you need to build, launch, and grow your online presence. Our intuitive interface makes it simple to get started in minutes.' },
{ label: 'Features', content: 'Drag-and-drop editor, responsive templates, custom domains, analytics dashboard, SEO tools, and integrations with your favorite services. Everything you need in one place.' },
{ label: 'Support', content: 'Our dedicated support team is available 24/7 to help you with any questions. Access our knowledge base, community forums, or reach out directly via live chat or email.' },
];
export const Tabs: UserComponent<TabsProps> = ({
tabs = defaultTabs,
style = {},
activeTabBg = '#3b82f6',
activeTabColor = '#ffffff',
inactiveTabBg = '#f1f5f9',
inactiveTabColor = '#64748b',
contentBg = '#ffffff',
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const [activeIndex, setActiveIndex] = useState(0);
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
padding: '60px 20px',
backgroundColor: '#ffffff',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
{/* Tab buttons */}
<div style={{ display: 'flex', gap: '2px', borderBottom: '2px solid #e2e8f0' }}>
{tabs.map((tab, i) => (
<button
key={i}
onClick={() => setActiveIndex(i)}
style={{
padding: '12px 24px',
fontSize: '14px',
fontWeight: '600',
border: 'none',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px',
cursor: 'pointer',
backgroundColor: i === activeIndex ? activeTabBg : inactiveTabBg,
color: i === activeIndex ? activeTabColor : inactiveTabColor,
transition: 'background-color 0.2s, color 0.2s',
}}
>
{tab.label}
</button>
))}
</div>
{/* Content panel */}
<div
style={{
padding: '24px',
backgroundColor: contentBg,
border: '1px solid #e2e8f0',
borderTop: 'none',
borderBottomLeftRadius: '8px',
borderBottomRightRadius: '8px',
fontSize: '14px',
lineHeight: '1.7',
color: '#4b5563',
minHeight: '100px',
}}
>
{tabs[activeIndex]?.content || ''}
</div>
</div>
</section>
);
};
/* ---------- Settings panel ---------- */
const TabsSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as TabsProps,
}));
const tabs = props.tabs || defaultTabs;
const inputStyle: CSSProperties = {
width: '100%', padding: '3px 6px', background: '#27272a', color: '#e4e4e7',
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 11,
};
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
const updateTab = (index: number, field: keyof TabItem, value: string) => {
setProp((p: TabsProps) => {
const updated = [...(p.tabs || defaultTabs)];
updated[index] = { ...updated[index], [field]: value };
p.tabs = updated;
});
};
const addTab = () => {
setProp((p: TabsProps) => {
p.tabs = [...(p.tabs || defaultTabs), { label: 'New Tab', content: 'Tab content goes here.' }];
});
};
const removeTab = (index: number) => {
setProp((p: TabsProps) => {
const updated = [...(p.tabs || defaultTabs)];
updated.splice(index, 1);
p.tabs = updated;
});
};
const colorSwatches = ['#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#18181b', '#ffffff', '#f1f5f9'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<div>
<label style={labelStyle}>Active Tab Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{colorSwatches.map((c) => (
<button
key={c}
onClick={() => setProp((p: TabsProps) => { p.activeTabBg = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.activeTabBg === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Active Tab Text Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#ffffff', '#18181b', '#1f2937', '#e2e8f0'].map((c) => (
<button
key={c}
onClick={() => setProp((p: TabsProps) => { p.activeTabColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.activeTabColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Inactive Tab Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#f1f5f9', '#e2e8f0', '#f8fafc', '#ffffff', '#27272a', '#18181b'].map((c) => (
<button
key={c}
onClick={() => setProp((p: TabsProps) => { p.inactiveTabBg = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.inactiveTabBg === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Inactive Tab Text Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#64748b', '#94a3b8', '#18181b', '#ffffff'].map((c) => (
<button
key={c}
onClick={() => setProp((p: TabsProps) => { p.inactiveTabColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.inactiveTabColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Content Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#1e293b'].map((c) => (
<button
key={c}
onClick={() => setProp((p: TabsProps) => { p.contentBg = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.contentBg === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
<div>
<label style={labelStyle}>Tabs</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{tabs.map((tab, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4 }}>
<input type="text" value={tab.label} onChange={(e) => updateTab(i, 'label', e.target.value)} placeholder="Label" style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removeTab(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
X
</button>
</div>
<textarea
value={tab.content}
onChange={(e) => updateTab(i, 'content', e.target.value)}
placeholder="Content"
rows={2}
style={{ ...inputStyle, resize: 'vertical' }}
/>
</div>
))}
</div>
<button
onClick={addTab}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Tab
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Tabs.craft = {
displayName: 'Tabs',
props: {
tabs: defaultTabs,
style: { backgroundColor: '#ffffff' },
activeTabBg: '#3b82f6',
activeTabColor: '#ffffff',
inactiveTabBg: '#f1f5f9',
inactiveTabColor: '#64748b',
contentBg: '#ffffff',
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: TabsSettings,
},
};
/* ---------- HTML export ---------- */
(Tabs as any).toHtml = (props: TabsProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const sectionStyle = cssPropsToString({
padding: '60px 20px',
...props.style,
});
const tabs = props.tabs || defaultTabs;
const activeTabBg = props.activeTabBg || '#3b82f6';
const activeTabColor = props.activeTabColor || '#ffffff';
const inactiveTabBg = props.inactiveTabBg || '#f1f5f9';
const inactiveTabColor = props.inactiveTabColor || '#64748b';
const contentBg = props.contentBg || '#ffffff';
const tabId = 'tabs_' + Math.random().toString(36).slice(2, 8);
const tabButtons = tabs.map((tab, i) => {
const isActive = i === 0;
return `<button onclick="${tabId}_switch(${i})" id="${tabId}_btn_${i}" style="padding:12px 24px;font-size:14px;font-weight:600;border:none;border-top-left-radius:8px;border-top-right-radius:8px;cursor:pointer;background-color:${isActive ? activeTabBg : inactiveTabBg};color:${isActive ? activeTabColor : inactiveTabColor}">${esc(tab.label)}</button>`;
}).join('\n ');
const tabPanels = tabs.map((tab, i) => {
return `<div id="${tabId}_panel_${i}" style="padding:24px;background-color:${contentBg};border:1px solid #e2e8f0;border-top:none;border-bottom-left-radius:8px;border-bottom-right-radius:8px;font-size:14px;line-height:1.7;color:#4b5563;min-height:100px;${i !== 0 ? 'display:none' : ''}">${esc(tab.content)}</div>`;
}).join('\n ');
const switchScript = `<script>
function ${tabId}_switch(idx){
var total=${tabs.length};
for(var i=0;i<total;i++){
document.getElementById('${tabId}_panel_'+i).style.display=i===idx?'':'none';
var btn=document.getElementById('${tabId}_btn_'+i);
btn.style.backgroundColor=i===idx?'${activeTabBg}':'${inactiveTabBg}';
btn.style.color=i===idx?'${activeTabColor}':'${inactiveTabColor}';
}
}
</script>`;
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:800px;margin:0 auto">
<div style="display:flex;gap:2px;border-bottom:2px solid #e2e8f0">
${tabButtons}
</div>
${tabPanels}
</div>
${switchScript}
</section>`,
};
};

View File

@@ -0,0 +1,421 @@
import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
interface Testimonial {
quote: string;
name: string;
title: string;
rating: number;
}
interface TestimonialsProps {
testimonials?: Testimonial[];
layout?: 'grid' | 'single';
columns?: number;
style?: CSSProperties;
cardBg?: string;
starColor?: string;
}
const defaultTestimonials: Testimonial[] = [
{ quote: 'This product has completely transformed our workflow. Highly recommended for any team.', name: 'Sarah Johnson', title: 'Marketing Director', rating: 5 },
{ quote: 'Outstanding support and an incredibly intuitive interface. We saw results from day one.', name: 'Michael Chen', title: 'CTO, TechStart', rating: 5 },
{ quote: 'The best investment we have made this year. Simple, powerful, and reliable.', name: 'Emily Rodriguez', title: 'Founder, DesignLab', rating: 4 },
];
function renderStars(count: number, color: string): React.ReactNode {
return (
<div style={{ display: 'flex', gap: '2px', justifyContent: 'center', marginBottom: '12px' }}>
{[1, 2, 3, 4, 5].map((i) => (
<i
key={i}
className={`fa ${i <= count ? 'fa-star' : 'fa-star-o'}`}
style={{ color, fontSize: '14px' }}
/>
))}
</div>
);
}
function starsHtml(count: number, color: string): string {
const stars = [1, 2, 3, 4, 5].map((i) =>
`<i class="fa ${i <= count ? 'fa-star' : 'fa-star-o'}" style="color:${color};font-size:14px"></i>`
).join('');
return `<div style="display:flex;gap:2px;justify-content:center;margin-bottom:12px">${stars}</div>`;
}
export const Testimonials: UserComponent<TestimonialsProps> = ({
testimonials = defaultTestimonials,
layout = 'grid',
columns = 3,
style = {},
cardBg = '#f8fafc',
starColor = '#f59e0b',
}) => {
const {
connectors: { connect, drag },
selected,
} = useNode((node) => ({
selected: node.events.selected,
}));
const [currentIndex, setCurrentIndex] = useState(0);
const cardStyle: CSSProperties = {
backgroundColor: cardBg,
borderRadius: '12px',
padding: '32px 24px',
textAlign: 'center',
border: '1px solid #e2e8f0',
};
const renderCard = (t: Testimonial, i: number) => (
<div key={i} style={cardStyle}>
{renderStars(t.rating, starColor)}
<p style={{ fontSize: '15px', color: '#374151', lineHeight: '1.7', marginBottom: '16px', fontStyle: 'italic', fontFamily: 'Inter, sans-serif' }}>
&ldquo;{t.quote}&rdquo;
</p>
<div style={{ fontWeight: '600', fontSize: '14px', color: '#18181b', fontFamily: 'Inter, sans-serif' }}>{t.name}</div>
<div style={{ fontSize: '13px', color: '#64748b', fontFamily: 'Inter, sans-serif' }}>{t.title}</div>
</div>
);
const items = testimonials.length > 0 ? testimonials : defaultTestimonials;
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
style={{
padding: '80px 20px',
backgroundColor: '#ffffff',
outline: selected ? '2px solid #3b82f6' : 'none',
...style,
}}
>
<div style={{ maxWidth: '1100px', margin: '0 auto' }}>
{layout === 'grid' ? (
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: '24px' }}>
{items.map((t, i) => renderCard(t, i))}
</div>
) : (
<div style={{ maxWidth: '600px', margin: '0 auto', position: 'relative' }}>
{renderCard(items[currentIndex] || items[0], currentIndex)}
{items.length > 1 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: '12px', marginTop: '20px' }}>
<button
onClick={() => setCurrentIndex((prev) => (prev - 1 + items.length) % items.length)}
style={{
width: 36, height: 36, borderRadius: '50%', border: '1px solid #d1d5db',
background: '#ffffff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, color: '#374151',
}}
>
<i className="fa fa-chevron-left" />
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
{items.map((_, i) => (
<div
key={i}
onClick={() => setCurrentIndex(i)}
style={{
width: 8, height: 8, borderRadius: '50%', cursor: 'pointer',
backgroundColor: i === currentIndex ? '#3b82f6' : '#d1d5db',
}}
/>
))}
</div>
<button
onClick={() => setCurrentIndex((prev) => (prev + 1) % items.length)}
style={{
width: 36, height: 36, borderRadius: '50%', border: '1px solid #d1d5db',
background: '#ffffff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, color: '#374151',
}}
>
<i className="fa fa-chevron-right" />
</button>
</div>
)}
</div>
)}
</div>
</section>
);
};
/* ---------- Settings panel ---------- */
const TestimonialsSettings: React.FC = () => {
const { actions: { setProp }, props } = useNode((node) => ({
props: node.data.props as TestimonialsProps,
}));
const items = props.testimonials || defaultTestimonials;
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,
};
const updateTestimonial = (index: number, field: keyof Testimonial, value: string | number) => {
setProp((p: TestimonialsProps) => {
const updated = [...(p.testimonials || defaultTestimonials)];
updated[index] = { ...updated[index], [field]: value };
p.testimonials = updated;
});
};
const addTestimonial = () => {
setProp((p: TestimonialsProps) => {
p.testimonials = [...(p.testimonials || defaultTestimonials), { quote: 'Great experience!', name: 'New Person', title: 'Role', rating: 5 }];
});
};
const removeTestimonial = (index: number) => {
setProp((p: TestimonialsProps) => {
const updated = [...(p.testimonials || defaultTestimonials)];
updated.splice(index, 1);
p.testimonials = updated;
});
};
const bgPresets = ['#ffffff', '#f8fafc', '#f1f5f9', '#18181b', '#0f172a'];
const cardBgPresets = ['#f8fafc', '#ffffff', '#f1f5f9', '#e2e8f0', '#27272a', '#1e293b'];
const starColorPresets = ['#f59e0b', '#eab308', '#ef4444', '#3b82f6', '#10b981', '#8b5cf6'];
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
{/* Layout */}
<div>
<label style={labelStyle}>Layout</label>
<div style={{ display: 'flex', gap: 4 }}>
<button
onClick={() => setProp((p: TestimonialsProps) => { p.layout = 'grid'; })}
style={{
flex: 1, padding: '6px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.layout === 'grid' ? '#3b82f6' : '#27272a',
color: props.layout === 'grid' ? '#fff' : '#a1a1aa',
fontWeight: 500,
}}
>
Grid
</button>
<button
onClick={() => setProp((p: TestimonialsProps) => { p.layout = 'single'; })}
style={{
flex: 1, padding: '6px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.layout === 'single' ? '#3b82f6' : '#27272a',
color: props.layout === 'single' ? '#fff' : '#a1a1aa',
fontWeight: 500,
}}
>
Single
</button>
</div>
</div>
{/* Columns (only for grid) */}
{props.layout === 'grid' && (
<div>
<label style={labelStyle}>Columns</label>
<div style={{ display: 'flex', gap: 4 }}>
{[1, 2, 3, 4].map((n) => (
<button
key={n}
onClick={() => setProp((p: TestimonialsProps) => { p.columns = n; })}
style={{
padding: '4px 10px', fontSize: 11, borderRadius: 4, cursor: 'pointer',
border: '1px solid #3f3f46',
background: props.columns === n ? '#3b82f6' : '#27272a',
color: '#e4e4e7',
}}
>
{n}
</button>
))}
</div>
</div>
)}
{/* Section background */}
<div>
<label style={labelStyle}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{bgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: TestimonialsProps) => { 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>
{/* Card background */}
<div>
<label style={labelStyle}>Card Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{cardBgPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: TestimonialsProps) => { p.cardBg = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.cardBg === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Star color */}
<div>
<label style={labelStyle}>Star Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{starColorPresets.map((c) => (
<button
key={c}
onClick={() => setProp((p: TestimonialsProps) => { p.starColor = c; })}
style={{
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
backgroundColor: c, cursor: 'pointer',
outline: props.starColor === c ? '2px solid #3b82f6' : 'none',
outlineOffset: 1,
}}
/>
))}
</div>
</div>
{/* Testimonials list */}
<div>
<label style={labelStyle}>Testimonials</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map((t, i) => (
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<input type="text" value={t.name} onChange={(e) => updateTestimonial(i, 'name', e.target.value)} placeholder="Name" style={{ ...inputStyle, flex: 1 }} />
<button
onClick={() => removeTestimonial(i)}
style={{ padding: '2px 6px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer' }}
>
X
</button>
</div>
<input type="text" value={t.title} onChange={(e) => updateTestimonial(i, 'title', e.target.value)} placeholder="Title/Role" style={inputStyle} />
<textarea
value={t.quote}
onChange={(e) => updateTestimonial(i, 'quote', e.target.value)}
placeholder="Quote..."
rows={2}
style={{ ...inputStyle, resize: 'vertical' }}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 11, color: '#a1a1aa' }}>Rating:</span>
{[1, 2, 3, 4, 5].map((n) => (
<i
key={n}
className={`fa ${n <= t.rating ? 'fa-star' : 'fa-star-o'}`}
onClick={() => updateTestimonial(i, 'rating', n)}
style={{ color: '#f59e0b', cursor: 'pointer', fontSize: 14 }}
/>
))}
</div>
</div>
))}
</div>
<button
onClick={addTestimonial}
style={{ marginTop: 6, width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
>
+ Add Testimonial
</button>
</div>
</div>
);
};
/* ---------- Craft config ---------- */
Testimonials.craft = {
displayName: 'Testimonials',
props: {
testimonials: defaultTestimonials,
layout: 'grid',
columns: 3,
style: { backgroundColor: '#ffffff' },
cardBg: '#f8fafc',
starColor: '#f59e0b',
},
rules: {
canDrag: () => true,
canMoveIn: () => false,
canMoveOut: () => true,
},
related: {
settings: TestimonialsSettings,
},
};
/* ---------- HTML export ---------- */
(Testimonials as any).toHtml = (props: TestimonialsProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const {
testimonials = defaultTestimonials,
layout = 'grid',
columns = 3,
style = {},
cardBg = '#f8fafc',
starColor = '#f59e0b',
} = props;
const items = testimonials.length > 0 ? testimonials : defaultTestimonials;
const sectionStyle = cssPropsToString({
padding: '80px 20px',
backgroundColor: '#ffffff',
...style,
});
const cardCss = `background-color:${cardBg};border-radius:12px;padding:32px 24px;text-align:center;border:1px solid #e2e8f0`;
const cards = items.map((t) => {
return `<div style="${cardCss}">
${starsHtml(t.rating, starColor)}
<p style="font-size:15px;color:#374151;line-height:1.7;margin-bottom:16px;font-style:italic;font-family:Inter,sans-serif">&ldquo;${esc(t.quote)}&rdquo;</p>
<div style="font-weight:600;font-size:14px;color:#18181b;font-family:Inter,sans-serif">${esc(t.name)}</div>
<div style="font-size:13px;color:#64748b;font-family:Inter,sans-serif">${esc(t.title)}</div>
</div>`;
}).join('\n ');
if (layout === 'single') {
// For single layout, export as grid with 1 column (simpler static export)
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:600px;margin:0 auto;display:grid;grid-template-columns:1fr;gap:24px">
${cards}
</div>
</section>`,
};
}
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:24px">
${cards}
</div>
</section>`,
};
};