2026-07-12 13:16:53 -07:00
|
|
|
import React, { CSSProperties, useState } from 'react';
|
2026-04-05 18:31:16 -07:00
|
|
|
import { useNode, UserComponent } from '@craftjs/core';
|
|
|
|
|
import { cssPropsToString } from '../../utils/style-helpers';
|
|
|
|
|
import { useSiteDesign } from '../../state/SiteDesignContext';
|
2026-07-12 12:05:57 -07:00
|
|
|
import { escapeHtml, escapeAttr, safeUrl } from '../../utils/escape';
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
/* ---------- 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' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/* ---------- 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>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ---------- 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,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/* ---------- 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' });
|
2026-07-12 12:05:57 -07:00
|
|
|
logoHtml = `<a href="${escapeAttr(safeUrl(logoUrl))}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><img src="${escapeAttr(safeUrl(props.logoImage))}" alt="${escapeAttr(props.logoText || 'Logo')}"${imgStyle ? ` style="${imgStyle}"` : ''} /></a>`;
|
2026-04-05 18:31:16 -07:00
|
|
|
} else {
|
|
|
|
|
const logoStyle = cssPropsToString({
|
|
|
|
|
fontWeight: '700',
|
|
|
|
|
fontSize: props.logoFontSize || '20px',
|
|
|
|
|
fontFamily: props.logoFontFamily || 'Inter, sans-serif',
|
|
|
|
|
color: props.logoColor || textCol,
|
|
|
|
|
});
|
2026-07-12 12:05:57 -07:00
|
|
|
logoHtml = `<a href="${escapeAttr(safeUrl(logoUrl))}" style="text-decoration:none;display:flex;align-items:center;flex-shrink:0"><span${logoStyle ? ` style="${logoStyle}"` : ''}>${escapeHtml(props.logoText || 'MySite')}</span></a>`;
|
2026-04-05 18:31:16 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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',
|
|
|
|
|
});
|
2026-07-12 12:05:57 -07:00
|
|
|
return `<a href="${escapeAttr(safeUrl(link.href || "#"))}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
|
2026-04-05 18:31:16 -07:00
|
|
|
}).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">
|
2026-07-12 11:49:10 -07:00
|
|
|
<span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span>
|
|
|
|
|
<span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span>
|
|
|
|
|
<span style="display:block;width:24px;height:2px;background-color:${escapeAttr(textCol)}"></span>
|
2026-04-05 18:31:16 -07:00
|
|
|
</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',
|
|
|
|
|
});
|
2026-07-12 12:05:57 -07:00
|
|
|
return `<a href="${escapeAttr(safeUrl(link.href || "#"))}" class="${cls}"${target}${linkStyle ? ` style="${linkStyle}"` : ''}>${escapeHtml(link.text)}</a>`;
|
2026-04-05 18:31:16 -07:00
|
|
|
}).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>`,
|
|
|
|
|
};
|
|
|
|
|
};
|