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:
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/* ---------- 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>`,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user