import React, { useCallback } from 'react'; import { useEditor } from '@craftjs/core'; import { SPACING_PRESETS, SHADOW_PRESETS, } from '../../../constants/presets'; import { StylePanelProps, SectionLabel, PresetButtonGrid, CollapsibleSection, ColorPickerField, navColorFields, btnActiveStyle, labelStyle, inputStyle, smallInputStyle, sectionGap, useNodeProp, SpacingControl, BorderControl, BorderValue, buildBorderShorthand, AnimationControl, VisibilityControl, } from './shared'; import { AssetPicker } from '../../../ui/AssetPicker'; import { usePages } from '../../../state/PageContext'; import { PageData } from '../../../types'; /* ---------- Link-to-page helpers (F1/F2: link picker + Sync with Pages) ---------- Mirrors the export convention used elsewhere: the first page is always the landing page and publishes to '/', every other page publishes to '/{slug}'. See PageContext.tsx's uniqueSlug/slugify and the landing-page-lock comment in renamePage() for why index 0 is special-cased this way. */ export function pageHref(page: PageData, index: number): string { return index === 0 ? '/' : `/${page.slug}`; } type LinkMode = 'page' | 'url' | 'anchor' | 'tel' | 'mailto'; function detectLinkMode(value: string, pageHrefs: string[]): LinkMode { const v = value || ''; if (pageHrefs.includes(v)) return 'page'; if (v.startsWith('#')) return 'anchor'; if (v.startsWith('tel:')) return 'tel'; if (v.startsWith('mailto:')) return 'mailto'; return 'url'; } /* ---------- LinkPicker ---------- Reused for every link-href field in this panel (standalone Logo's href, Navbar's logoUrl, and each Navbar/Menu link item's href): a dropdown of the site's PAGES (read-only via usePages() -- PageContext itself is Wave-2's territory) plus manual URL / #anchor / tel: / mailto: entry. safeUrl (in toHtml) already allows tel:/mailto: schemes, so no export-side change is needed for those. */ interface LinkPickerProps { value: string; onChange: (value: string) => void; } export const LinkPicker: React.FC = ({ value, onChange }) => { const { pages } = usePages(); const pageOptions = pages.map((p, i) => ({ id: p.id, name: p.name, href: pageHref(p, i) })); const mode = detectLinkMode(value || '', pageOptions.map((p) => p.href)); const switchMode = (next: LinkMode) => { if (next === mode) return; if (next === 'page') onChange(pageOptions[0]?.href || '/'); else if (next === 'anchor') onChange('#'); else if (next === 'tel') onChange('tel:'); else if (next === 'mailto') onChange('mailto:'); else onChange(''); }; return (
{mode === 'page' && ( pageOptions.length > 0 ? ( ) : (
No pages yet
) )} {mode === 'anchor' && ( onChange(e.target.value.startsWith('#') ? e.target.value : `#${e.target.value}`)} placeholder="#section-id" style={inputStyle} /> )} {mode === 'tel' && ( onChange(`tel:${e.target.value}`)} placeholder="+15551234567" style={inputStyle} /> )} {mode === 'mailto' && ( onChange(`mailto:${e.target.value}`)} placeholder="name@example.com" style={inputStyle} /> )} {mode === 'url' && ( onChange(e.target.value)} placeholder="https://example.com or /page" style={inputStyle} /> )}
); }; /* Parses a `border` shorthand ("2px solid #hex" / "none") back into the { width, style, color } shape BorderControl edits. */ function parseBorderShorthand(v: string | undefined): BorderValue { if (!v || v === 'none') return { width: '', style: 'none', color: '#000000' }; const m = v.trim().match(/^(\S+)\s+(\S+)\s+(.+)$/); if (!m) return { width: '', style: 'none', color: '#000000' }; return { width: m[1], style: m[2], color: m[3] }; } function capitalize(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } /* ---------- NAV / MENU / LOGO / FOOTER ---------- */ export const NavStylePanel: React.FC = ({ selectedId, nodeProps }) => { const { actions } = useEditor(); const { setProp, setPropStyle } = useNodeProp(selectedId); const { pages } = usePages(); const links: any[] = nodeProps.links || []; const updateLink = useCallback((index: number, field: string, value: any) => { actions.setProp(selectedId, (props: any) => { const updated = [...(props.links || [])]; updated[index] = { ...updated[index], [field]: value }; props.links = updated; }); }, [actions, selectedId]); const addLink = useCallback(() => { actions.setProp(selectedId, (props: any) => { props.links = [...(props.links || []), { text: 'New Link', href: '#' }]; }); }, [actions, selectedId]); const removeLink = useCallback((index: number) => { actions.setProp(selectedId, (props: any) => { const updated = [...(props.links || [])]; updated.splice(index, 1); props.links = updated; }); }, [actions, selectedId]); /* F2: (re)populate the links array from the current pages list -- label = page name, href = '/' for the landing page else '/{slug}'. Any existing CTA link (isCta: true) is preserved (appended after the freshly-generated page links) rather than being wiped, matching the legacy GrapesJS builder's "Sync with Pages" behavior. */ const syncWithPages = useCallback(() => { actions.setProp(selectedId, (props: any) => { const existing: any[] = props.links || []; const ctaLinks = existing.filter((l) => l.isCta); const pageLinks = pages.map((p, i) => ({ text: p.name, href: pageHref(p, i) })); props.links = [...pageLinks, ...ctaLinks]; }); }, [actions, selectedId, pages]); /* Detect standalone Logo vs Navbar/Menu */ const isStandaloneLogo = nodeProps.type !== undefined && (nodeProps.type === 'text' || nodeProps.type === 'image') && nodeProps.logoText === undefined; /* Color controls, derived from the props the selected component actually has */ const colorFields = navColorFields(nodeProps); /* Menu layout controls (alignment/orientation/gap/font size). These props are unique to the Menu component — Navbar uses navAlignment and has no orientation/gap/fontSize — so guarding on their presence scopes this section to the Menu without leaking into Navbar or a standalone Logo. */ const hasMenuLayout = !isStandaloneLogo && ( nodeProps.alignment !== undefined || nodeProps.orientation !== undefined || nodeProps.gap !== undefined || nodeProps.fontSize !== undefined ); const GAP_PRESETS = ['8px', '16px', '24px', '32px', '40px'].map((g) => ({ label: g, value: g })); /* Box-model / animation / visibility values, read off `style` (margin, padding, border, boxShadow, opacity) or top-level props (animation, hideOn*). This panel is only ever mounted for the 4 owned components (Navbar/Menu/Logo/Footer), which all now carry these props (see each component's .craft.props), so -- unlike the Links/Colors sections above, which are shared across a genuinely disparate prop schema -- these sections render unconditionally rather than gating on presence. */ const style = nodeProps.style || {}; return ( <> {/* Standalone Logo component settings */} {isStandaloneLogo && (
{nodeProps.type === 'text' && ( <>
setProp('text', e.target.value)} style={inputStyle} />
setProp('fontSize', e.target.value)} placeholder="20px" style={inputStyle} />
setProp('color', v)} /> )} {nodeProps.type === 'image' && ( <>
setProp('imageSrc', url)} variant="full" />
setProp('imageWidth', e.target.value)} placeholder="120px" style={inputStyle} />
)} setProp('href', v)} />
)} {/* Navbar Logo settings */} {nodeProps.logoText !== undefined && (
setProp('logoText', e.target.value)} style={inputStyle} />
{nodeProps.logoImage !== undefined && (
setProp('logoImage', url)} variant="full" />
)} {nodeProps.logoUrl !== undefined && ( setProp('logoUrl', v)} /> )}
)} {/* Links (not shown for standalone Logo, or for components -- like Footer -- that don't carry a `links` array at all). */} {!isStandaloneLogo && nodeProps.links !== undefined && (
{links.map((link, i) => (
updateLink(i, 'text', e.target.value)} placeholder="Text" style={{ ...smallInputStyle, flex: 1 }} />
updateLink(i, 'href', v)} />
))}
)} {/* Colors (not shown for standalone Logo - it has its own color picker). Fields are derived from whichever color props the selected component actually has, so Menu (linkColor/ctaBg/…) and Navbar (backgroundColor/…) each get the right controls instead of an empty section. */} {!isStandaloneLogo && colorFields.length > 0 && ( {colorFields.map((f) => ( setProp(f.key, v)} /> ))} )} {/* Menu layout (alignment / orientation / gap / font size) */} {hasMenuLayout && ( {nodeProps.alignment !== undefined && (
{(['left', 'center', 'right'] as const).map((a) => ( ))}
)} {nodeProps.orientation !== undefined && (
{(['horizontal', 'vertical'] as const).map((o) => ( ))}
)} {nodeProps.gap !== undefined && (
setProp('gap', v)} />
)} {nodeProps.fontSize !== undefined && (
setProp('fontSize', e.target.value)} placeholder="14px" style={inputStyle} />
)}
)} {/* Box model: margin + padding (per-side, via style.*). The old single "Padding" preset row is folded into the Padding SpacingControl below (still writes to style.padding when linked, matching the previous behavior exactly). */} setPropStyle(`margin${capitalize(side)}`, v)} /> setPropStyle(`padding${capitalize(side)}`, v)} presets={SPACING_PRESETS} /> {/* Border & Effects: border, box-shadow, opacity */} setPropStyle('border', buildBorderShorthand(v))} />
Box Shadow setPropStyle('boxShadow', v)} />
setPropStyle('opacity', String(Number(e.target.value) / 100))} style={{ width: '100%' }} />
{/* Entrance animation */} actions.setProp(selectedId, (p: any) => { p.animation = v.animation; p.animationDelay = v.animationDelay; })} /> {/* Responsive visibility */} actions.setProp(selectedId, (p: any) => { p.hideOnDesktop = v.hideOnDesktop; p.hideOnTablet = v.hideOnTablet; p.hideOnMobile = v.hideOnMobile; })} /> ); };