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:
510
craft/src/components/basic/Menu.tsx
Normal file
510
craft/src/components/basic/Menu.tsx
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/* ---------- 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>`,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user