Files
site-builder/craft/src/components/basic/Menu.tsx

511 lines
18 KiB
TypeScript
Raw Normal View History

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