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
+232
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>`,
};
};
+119
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}"` : ''} />` };
};
+153
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>` };
};
+181
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}>` };
};
+127
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 || '' };
};
+325
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>` };
};
+418
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>`,
};
};
+510
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>`,
};
};
+929
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>`,
};
};
+204
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>`,
};
};
+444
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>`,
};
};
+107
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>` };
};
+230
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>`,
};
};
+158
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>` };
};