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 { 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: any): string { str = String(str ?? ""); return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } /* ---------- Component ---------- */ export const Navbar: UserComponent = ({ 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(null); return ( ); }; /* ---------- 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(null); const [showBrowser, setShowBrowser] = useState(false); const [browserAssets, setBrowserAssets] = useState([]); const [browserLoading, setBrowserLoading] = useState(false); /* Drag state for reordering */ const [dragIdx, setDragIdx] = useState(null); const [dragOverIdx, setDragOverIdx] = useState(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) => { 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 (
{/* ===== Logo Section ===== */}
{/* Logo type toggle */}
{logoType === 'text' ? ( <> {/* Text logo controls */}
setProp((p: NavbarProps) => { p.logoText = e.target.value; })} style={inputStyle} />
setProp((p: NavbarProps) => { p.logoFontSize = e.target.value; })} placeholder="20px" style={inputStyle} />
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' }} />
) : ( <> {/* Image logo controls */} {props.logoImage ? (
) : (
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); }} > Drop logo or click to upload
)}
{/* Browse grid */} {showBrowser && (
{browserAssets.map(asset => (
{ 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'; }} > {asset.name}
))} {browserAssets.length === 0 && (

No images uploaded yet.

)}
)} { const file = e.target.files?.[0]; if (file) handleLogoUpload(file); e.target.value = ''; }} /> {/* URL input */}
setProp((p: NavbarProps) => { p.logoImage = e.target.value; })} placeholder="Or paste image URL..." style={{ ...inputStyle, fontSize: 10, color: '#71717a' }} />
setProp((p: NavbarProps) => { p.logoWidth = e.target.value; })} placeholder="120px" style={inputStyle} />
)} {/* Logo link URL (shared) */}
setProp((p: NavbarProps) => { p.logoUrl = e.target.value; })} placeholder="/" style={inputStyle} />
{/* ===== Nav Style Section ===== */}
{/* Background color */}
{bgPresets.map((c) => (
{/* Text color */}
{textColorPresets.map((c) => (
{/* Link hover color */}
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' }} /> {props.hoverColor || '#3b82f6'}
{/* CTA button colors */}
BG 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' }} />
Text 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' }} />
{/* Padding presets */}
{PADDING_PRESETS.map((p) => ( ))}
{/* Alignment */}
{(['left', 'center', 'right', 'space-between'] as const).map((a) => ( ))}
{/* Sticky toggle */}
{/* Design token quick apply */}
{/* ===== Links Section ===== */}
{links.map((link, i) => (
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 */}
updateLink(i, 'text', e.target.value)} placeholder="Text" style={{ ...inputStyle, flex: 1 }} />
{/* Row 2: URL */} updateLink(i, 'href', e.target.value)} placeholder="URL (e.g. /about or https://...)" style={inputStyle} /> {/* Row 3: checkboxes */}
))}
{/* Add link button */} {/* Add page dropdown */}
); }; /* ---------- 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 = `${esc(props.logoText || 'Logo')}`; } else { const logoStyle = cssPropsToString({ fontWeight: '700', fontSize: props.logoFontSize || '20px', fontFamily: props.logoFontFamily || 'Inter, sans-serif', color: props.logoColor || textCol, }); logoHtml = `${esc(props.logoText || 'MySite')}`; } // 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 `${esc(link.text)}`; }).join('\n '); // Hamburger HTML for mobile const hamburgerHtml = mobile ? `\n ` : ''; // Hover CSS const hoverCss = ``; // 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 `${esc(link.text)}`; }).join('\n '); return { html: `${hoverCss} ${logoHtml}${hamburgerHtml} `, }; };