@morphika/webframe 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/admin/assets.ts +4 -0
- package/admin/database.ts +4 -0
- package/admin/index.ts +6 -0
- package/admin/login.ts +4 -0
- package/admin/navigation.ts +4 -0
- package/admin/pages-editor.ts +4 -0
- package/admin/pages.ts +4 -0
- package/admin/projects-editor.ts +4 -0
- package/admin/projects.ts +4 -0
- package/admin/settings.ts +4 -0
- package/admin/setup.ts +4 -0
- package/admin/storage.ts +4 -0
- package/admin/styles.ts +4 -0
- package/app/(site)/[slug]/loading.tsx +20 -0
- package/app/(site)/[slug]/page.tsx +83 -0
- package/app/(site)/error.tsx +32 -0
- package/app/(site)/layout.tsx +53 -0
- package/app/(site)/loading.tsx +20 -0
- package/app/(site)/not-found.tsx +41 -0
- package/app/(site)/page.tsx +43 -0
- package/app/(site)/preview/page.tsx +99 -0
- package/app/(site)/work/[slug]/loading.tsx +23 -0
- package/app/(site)/work/[slug]/page.tsx +84 -0
- package/app/admin/assets/page.tsx +573 -0
- package/app/admin/database/page.tsx +302 -0
- package/app/admin/error.tsx +53 -0
- package/app/admin/layout.tsx +273 -0
- package/app/admin/login/page.tsx +88 -0
- package/app/admin/navigation/page.tsx +157 -0
- package/app/admin/page.tsx +17 -0
- package/app/admin/pages/[slug]/page.tsx +849 -0
- package/app/admin/pages/page.tsx +588 -0
- package/app/admin/projects/[slug]/page.tsx +3 -0
- package/app/admin/projects/page.tsx +669 -0
- package/app/admin/settings/page.tsx +132 -0
- package/app/admin/setup/page.tsx +64 -0
- package/app/admin/storage/page.tsx +518 -0
- package/app/admin/styles/page.tsx +243 -0
- package/app/api/admin/assets/file/route.ts +81 -0
- package/app/api/admin/assets/health/route.ts +170 -0
- package/app/api/admin/assets/register/route.ts +163 -0
- package/app/api/admin/assets/registry/route.ts +98 -0
- package/app/api/admin/assets/relink/confirm/route.ts +242 -0
- package/app/api/admin/assets/relink/route.ts +202 -0
- package/app/api/admin/assets/scan/route.ts +271 -0
- package/app/api/admin/auth/route.ts +160 -0
- package/app/api/admin/custom-sections/[slug]/route.ts +159 -0
- package/app/api/admin/custom-sections/route.ts +127 -0
- package/app/api/admin/database/route.ts +53 -0
- package/app/api/admin/pages/[slug]/duplicate/route.ts +91 -0
- package/app/api/admin/pages/[slug]/route.ts +617 -0
- package/app/api/admin/pages/[slug]/set-home/route.ts +76 -0
- package/app/api/admin/pages/route.ts +129 -0
- package/app/api/admin/preview/route.ts +53 -0
- package/app/api/admin/r2/connect/route.ts +181 -0
- package/app/api/admin/r2/delete/route.ts +198 -0
- package/app/api/admin/r2/disconnect/route.ts +42 -0
- package/app/api/admin/r2/rename/route.ts +265 -0
- package/app/api/admin/r2/status/route.ts +106 -0
- package/app/api/admin/r2/upload-url/route.ts +148 -0
- package/app/api/admin/revalidate/route.ts +55 -0
- package/app/api/admin/settings/route.ts +279 -0
- package/app/api/admin/setup/complete/route.ts +51 -0
- package/app/api/admin/setup/route.ts +118 -0
- package/app/api/admin/storage/switch/route.ts +117 -0
- package/app/api/admin/styles/fonts/route.ts +97 -0
- package/app/api/admin/styles/route.ts +304 -0
- package/app/api/assets/[...path]/route.ts +98 -0
- package/app/api/custom-sections/[id]/route.ts +43 -0
- package/app/api/draft-mode/disable/route.ts +10 -0
- package/app/api/draft-mode/enable/route.ts +26 -0
- package/app/api/projects/route.ts +42 -0
- package/app/api/styles/route.ts +88 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +7 -0
- package/app/layout.tsx +53 -0
- package/app/robots.ts +17 -0
- package/app/sitemap.ts +48 -0
- package/app/studio/[[...index]]/page.tsx +8 -0
- package/components/admin/MetadataEditor.tsx +173 -0
- package/components/admin/PublishToggle.tsx +130 -0
- package/components/admin/icons.tsx +40 -0
- package/components/admin/nav-builder/NavBuilder.tsx +182 -0
- package/components/admin/nav-builder/NavBuilderGrid.tsx +326 -0
- package/components/admin/nav-builder/NavGeneralSettings.tsx +275 -0
- package/components/admin/nav-builder/NavGridCell.tsx +48 -0
- package/components/admin/nav-builder/NavGridItem.tsx +189 -0
- package/components/admin/nav-builder/NavItemSettings.tsx +288 -0
- package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -0
- package/components/admin/nav-builder/NavLivePreview.tsx +125 -0
- package/components/admin/nav-builder/NavSettingsFields.tsx +248 -0
- package/components/admin/nav-builder/NavSettingsPanel.tsx +127 -0
- package/components/admin/nav-builder/index.ts +10 -0
- package/components/admin/nav-builder/nav-builder-utils.ts +238 -0
- package/components/admin/setup-wizard/BrandingStep.tsx +218 -0
- package/components/admin/setup-wizard/DatabaseStep.tsx +331 -0
- package/components/admin/setup-wizard/DoneStep.tsx +187 -0
- package/components/admin/setup-wizard/SetupWizard.tsx +166 -0
- package/components/admin/setup-wizard/StorageStep.tsx +308 -0
- package/components/admin/setup-wizard/WelcomeStep.tsx +96 -0
- package/components/admin/setup-wizard/index.ts +9 -0
- package/components/admin/styles/ColorsEditor.tsx +214 -0
- package/components/admin/styles/FontsEditor.tsx +258 -0
- package/components/admin/styles/GridLayoutEditor.tsx +292 -0
- package/components/admin/styles/LinksButtonsEditor.tsx +120 -0
- package/components/admin/styles/TypographyEditor.tsx +266 -0
- package/components/admin/styles/index.ts +9 -0
- package/components/admin/styles/shared.tsx +68 -0
- package/components/blocks/BlockRenderer.tsx +404 -0
- package/components/blocks/ButtonBlockRenderer.tsx +52 -0
- package/components/blocks/CoverBlockRenderer.tsx +239 -0
- package/components/blocks/CustomSectionInstanceRenderer.tsx +82 -0
- package/components/blocks/EnterAnimationWrapper.tsx +140 -0
- package/components/blocks/HoverAnimationWrapper.tsx +308 -0
- package/components/blocks/ImageBlockRenderer.tsx +61 -0
- package/components/blocks/ImageGridBlockRenderer.tsx +545 -0
- package/components/blocks/PageBackground.tsx +28 -0
- package/components/blocks/PageNavAnimation.tsx +35 -0
- package/components/blocks/PageNavColor.tsx +24 -0
- package/components/blocks/PageRenderer.tsx +142 -0
- package/components/blocks/ParallaxGroupRenderer.tsx +448 -0
- package/components/blocks/ParallaxSlideRenderer.tsx +175 -0
- package/components/blocks/ProjectGridBlockRenderer.tsx +556 -0
- package/components/blocks/SectionRenderer.tsx +170 -0
- package/components/blocks/SectionV2Renderer.tsx +330 -0
- package/components/blocks/ShaderCanvas.tsx +392 -0
- package/components/blocks/SpacerBlockRenderer.tsx +17 -0
- package/components/blocks/TextBlockRenderer.tsx +87 -0
- package/components/blocks/TypewriterRichText.tsx +464 -0
- package/components/blocks/TypewriterWrapper.tsx +149 -0
- package/components/blocks/VideoBlockRenderer.tsx +304 -0
- package/components/blocks/index.ts +2 -0
- package/components/builder/AssetBrowser.tsx +2 -0
- package/components/builder/BlockLivePreview.tsx +101 -0
- package/components/builder/BlockTypePicker.tsx +178 -0
- package/components/builder/BuilderCanvas.tsx +354 -0
- package/components/builder/CanvasMinimap.tsx +200 -0
- package/components/builder/CanvasToolbar.tsx +202 -0
- package/components/builder/ColorPicker.tsx +243 -0
- package/components/builder/ColorSwatchPicker.tsx +274 -0
- package/components/builder/ColumnDragContext.tsx +51 -0
- package/components/builder/ColumnDragOverlay.tsx +110 -0
- package/components/builder/CustomSectionInstanceCard.tsx +97 -0
- package/components/builder/DeviceFrame.tsx +123 -0
- package/components/builder/DndWrapper.tsx +337 -0
- package/components/builder/InsertionLines.tsx +186 -0
- package/components/builder/ParallaxGroupCanvas.tsx +228 -0
- package/components/builder/ParallaxSlideHeader.tsx +113 -0
- package/components/builder/ReadOnlyFrame.tsx +417 -0
- package/components/builder/SectionEditorBar.tsx +288 -0
- package/components/builder/SectionTypePicker.tsx +422 -0
- package/components/builder/SectionV2Canvas.tsx +297 -0
- package/components/builder/SectionV2Column.tsx +488 -0
- package/components/builder/SettingsPanel.tsx +911 -0
- package/components/builder/SortableBlock.tsx +230 -0
- package/components/builder/SortableRow.tsx +362 -0
- package/components/builder/VirtualAssetGrid.tsx +397 -0
- package/components/builder/asset-browser/AssetBrowser.tsx +178 -0
- package/components/builder/asset-browser/FileLightbox.tsx +116 -0
- package/components/builder/asset-browser/FolderTreeItem.tsx +55 -0
- package/components/builder/asset-browser/R2BrowserContent.tsx +436 -0
- package/components/builder/asset-browser/R2ContextMenu.tsx +98 -0
- package/components/builder/asset-browser/VideoThumbnail.tsx +63 -0
- package/components/builder/asset-browser/helpers.ts +88 -0
- package/components/builder/asset-browser/index.ts +1 -0
- package/components/builder/asset-browser/types.ts +49 -0
- package/components/builder/asset-browser/useAssetBrowser.ts +344 -0
- package/components/builder/asset-browser/useR2DragDrop.ts +116 -0
- package/components/builder/asset-browser/useR2Operations.ts +189 -0
- package/components/builder/blockStyles.tsx +295 -0
- package/components/builder/editors/ButtonBlockEditor.tsx +184 -0
- package/components/builder/editors/CoverBlockEditor.tsx +488 -0
- package/components/builder/editors/EnterAnimationPicker.tsx +297 -0
- package/components/builder/editors/HoverEffectPicker.tsx +209 -0
- package/components/builder/editors/ImageBlockEditor.tsx +206 -0
- package/components/builder/editors/ImageGridBlockEditor.tsx +386 -0
- package/components/builder/editors/ProjectGridEditor.tsx +648 -0
- package/components/builder/editors/SpacerBlockEditor.tsx +167 -0
- package/components/builder/editors/StaggerSettings.tsx +108 -0
- package/components/builder/editors/TextAlignmentIcons.tsx +39 -0
- package/components/builder/editors/TextBlockEditor.tsx +462 -0
- package/components/builder/editors/TextStylePicker.tsx +183 -0
- package/components/builder/editors/VideoBlockEditor.tsx +278 -0
- package/components/builder/editors/index.ts +10 -0
- package/components/builder/editors/shared.tsx +345 -0
- package/components/builder/hooks/useColumnDrag.ts +472 -0
- package/components/builder/hooks/useColumnResize.ts +221 -0
- package/components/builder/index.ts +12 -0
- package/components/builder/live-preview/LiveButtonPreview.tsx +38 -0
- package/components/builder/live-preview/LiveCoverPreview.tsx +146 -0
- package/components/builder/live-preview/LiveImageGridPreview.tsx +123 -0
- package/components/builder/live-preview/LiveImagePreview.tsx +107 -0
- package/components/builder/live-preview/LiveProjectGridPreview.tsx +1010 -0
- package/components/builder/live-preview/LiveSpacerPreview.tsx +9 -0
- package/components/builder/live-preview/LiveTextEditor.tsx +198 -0
- package/components/builder/live-preview/LiveVideoPreview.tsx +98 -0
- package/components/builder/live-preview/index.ts +10 -0
- package/components/builder/live-preview/shared.tsx +153 -0
- package/components/builder/settings-panel/BlockLayoutTab.tsx +532 -0
- package/components/builder/settings-panel/BlockSettings.tsx +94 -0
- package/components/builder/settings-panel/ColumnV2Settings.tsx +160 -0
- package/components/builder/settings-panel/LayoutTab.tsx +310 -0
- package/components/builder/settings-panel/PageSettings.tsx +200 -0
- package/components/builder/settings-panel/ParallaxGroupSettings.tsx +118 -0
- package/components/builder/settings-panel/ParallaxSlideSettings.tsx +178 -0
- package/components/builder/settings-panel/SectionV2AnimationTab.tsx +103 -0
- package/components/builder/settings-panel/SectionV2LayoutTab.tsx +312 -0
- package/components/builder/settings-panel/SectionV2Settings.tsx +323 -0
- package/components/builder/settings-panel/TRBLInputs.tsx +51 -0
- package/components/builder/settings-panel/index.ts +19 -0
- package/components/builder/settings-panel/responsive-helpers.ts +524 -0
- package/components/ui/CustomCursor.tsx +118 -0
- package/components/ui/NavContentLightbox.tsx +152 -0
- package/components/ui/Navbar.tsx +582 -0
- package/components/ui/PortfolioTracker.tsx +87 -0
- package/components/ui/ScrollToTop.tsx +47 -0
- package/lib/animation/enter-presets.ts +147 -0
- package/lib/animation/enter-resolve.ts +90 -0
- package/lib/animation/enter-types.ts +128 -0
- package/lib/animation/hover-effect-presets.ts +210 -0
- package/lib/animation/hover-effect-types.ts +126 -0
- package/lib/asset-retry.ts +111 -0
- package/lib/assets.ts +92 -0
- package/lib/audit.ts +35 -0
- package/lib/auth-token.ts +94 -0
- package/lib/auth.ts +13 -0
- package/lib/builder/cascade-helpers.ts +51 -0
- package/lib/builder/cascade.ts +533 -0
- package/lib/builder/constants.ts +103 -0
- package/lib/builder/defaults.ts +182 -0
- package/lib/builder/history.ts +48 -0
- package/lib/builder/index.ts +21 -0
- package/lib/builder/layout-styles.ts +344 -0
- package/lib/builder/masonry.ts +166 -0
- package/lib/builder/responsive.ts +156 -0
- package/lib/builder/serializer.ts +845 -0
- package/lib/builder/store-blocks.ts +193 -0
- package/lib/builder/store-canvas.ts +319 -0
- package/lib/builder/store-helpers.ts +490 -0
- package/lib/builder/store-sections.ts +709 -0
- package/lib/builder/store.ts +333 -0
- package/lib/builder/templates.ts +297 -0
- package/lib/builder/types.ts +374 -0
- package/lib/builder/utils.ts +37 -0
- package/lib/color-utils.ts +116 -0
- package/lib/config/index.ts +57 -0
- package/lib/config/types.ts +122 -0
- package/lib/contexts/AssetContext.tsx +79 -0
- package/lib/contexts/NavAnimationContext.tsx +44 -0
- package/lib/contexts/NavColorContext.tsx +38 -0
- package/lib/contexts/PageExitContext.tsx +194 -0
- package/lib/contexts/ThumbStatusContext.tsx +83 -0
- package/lib/csrf-client.ts +34 -0
- package/lib/csrf.ts +68 -0
- package/lib/format-utils.ts +24 -0
- package/lib/hooks/useViewport.ts +42 -0
- package/lib/logger.ts +81 -0
- package/lib/revalidate.ts +23 -0
- package/lib/sanitize.ts +91 -0
- package/lib/sanity/client.ts +8 -0
- package/lib/sanity/queries.ts +486 -0
- package/lib/sanity/types.ts +869 -0
- package/lib/sanity/writeClient.ts +24 -0
- package/lib/security.ts +402 -0
- package/lib/setup/detect.ts +156 -0
- package/lib/shader/glsl/index.ts +27 -0
- package/lib/shader/glsl/pixelate.ts +51 -0
- package/lib/shader/glsl/rgb-shift.ts +45 -0
- package/lib/shader/glsl/ripple.ts +46 -0
- package/lib/shader/glsl/vertex.ts +14 -0
- package/lib/storage/index.ts +211 -0
- package/lib/storage/r2-adapter.ts +286 -0
- package/lib/storage/types.ts +125 -0
- package/lib/styles/provider.tsx +267 -0
- package/lib/thumbnails/generate.ts +151 -0
- package/lib/utils.ts +6 -0
- package/package.json +212 -0
- package/sanity/compose.ts +65 -0
- package/sanity/sanity.config.ts +126 -0
- package/sanity/schemas/assetRegistry.ts +301 -0
- package/sanity/schemas/blocks/blockLayout.ts +90 -0
- package/sanity/schemas/blocks/buttonBlock.ts +82 -0
- package/sanity/schemas/blocks/coverBlock.ts +229 -0
- package/sanity/schemas/blocks/imageBlock.ts +58 -0
- package/sanity/schemas/blocks/imageGridBlock.ts +112 -0
- package/sanity/schemas/blocks/index.ts +9 -0
- package/sanity/schemas/blocks/projectGridBlock.ts +251 -0
- package/sanity/schemas/blocks/spacerBlock.ts +41 -0
- package/sanity/schemas/blocks/textBlock.ts +139 -0
- package/sanity/schemas/blocks/videoBlock.ts +80 -0
- package/sanity/schemas/customSection.ts +69 -0
- package/sanity/schemas/customSectionInstance.ts +163 -0
- package/sanity/schemas/index.ts +111 -0
- package/sanity/schemas/objects/enterAnimationConfig.ts +72 -0
- package/sanity/schemas/objects/hoverEffectConfig.ts +90 -0
- package/sanity/schemas/objects/parallaxGroup.ts +66 -0
- package/sanity/schemas/objects/parallaxSlide.ts +217 -0
- package/sanity/schemas/objects/typewriterConfig.ts +38 -0
- package/sanity/schemas/page.ts +162 -0
- package/sanity/schemas/pageSection.ts +157 -0
- package/sanity/schemas/pageSectionV2.ts +269 -0
- package/sanity/schemas/siteSettings.ts +256 -0
- package/sanity/schemas/siteStyles.ts +210 -0
- package/site/error.ts +4 -0
- package/site/index.ts +8 -0
- package/site/not-found.ts +4 -0
- package/site/page.ts +4 -0
- package/site/preview.ts +4 -0
- package/site/robots.ts +4 -0
- package/site/sitemap.ts +4 -0
- package/site/work.ts +4 -0
- package/studio/index.ts +4 -0
- package/styles/admin.css +85 -0
- package/styles/animations.css +237 -0
- package/styles/base.css +148 -0
- package/styles/globals.css +10 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
4
|
+
import { usePathname } from "next/navigation";
|
|
5
|
+
import Link from "next/link";
|
|
6
|
+
import type { NavItem, NavDesign, NavEntrancePreset } from "../../lib/sanity/types";
|
|
7
|
+
import { useNavColor } from "../../lib/contexts/NavColorContext";
|
|
8
|
+
import { useNavAnimation } from "../../lib/contexts/NavAnimationContext";
|
|
9
|
+
import { usePageExit } from "../../lib/contexts/PageExitContext";
|
|
10
|
+
import { getSiteConfig } from "../../lib/config";
|
|
11
|
+
import NavContentLightbox from "./NavContentLightbox";
|
|
12
|
+
|
|
13
|
+
// ============================================
|
|
14
|
+
// Color variant mapping
|
|
15
|
+
// ============================================
|
|
16
|
+
|
|
17
|
+
const colorMap: Record<string, string> = {
|
|
18
|
+
"yellow-lime": "text-brand-accent",
|
|
19
|
+
yellow: "text-brand-accent-alt",
|
|
20
|
+
"red-coral": "text-brand-secondary",
|
|
21
|
+
blue: "text-brand-primary",
|
|
22
|
+
green: "text-brand-accent-2",
|
|
23
|
+
white: "text-brand-text",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// ============================================
|
|
27
|
+
// NavLink — shared link component (eliminates duplication)
|
|
28
|
+
// ============================================
|
|
29
|
+
|
|
30
|
+
interface NavLinkProps {
|
|
31
|
+
item: NavItem;
|
|
32
|
+
className: string;
|
|
33
|
+
style?: React.CSSProperties;
|
|
34
|
+
onClick?: () => void;
|
|
35
|
+
currentPath: string;
|
|
36
|
+
activeClassName?: string;
|
|
37
|
+
/** Called when a content link is clicked — opens lightbox */
|
|
38
|
+
onContentClick?: (item: NavItem) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function NavLink({
|
|
42
|
+
item,
|
|
43
|
+
className,
|
|
44
|
+
style,
|
|
45
|
+
onClick,
|
|
46
|
+
currentPath,
|
|
47
|
+
activeClassName = "opacity-60",
|
|
48
|
+
onContentClick,
|
|
49
|
+
}: NavLinkProps) {
|
|
50
|
+
// Content links render as buttons that open a lightbox
|
|
51
|
+
if (item.link_type === "content") {
|
|
52
|
+
const hasContent = item.content_asset || item.content_url;
|
|
53
|
+
return (
|
|
54
|
+
<button
|
|
55
|
+
className={className}
|
|
56
|
+
style={{ ...style, cursor: hasContent ? "pointer" : "default", opacity: hasContent ? 1 : 0.4, background: "none", border: "none", padding: 0 }}
|
|
57
|
+
onClick={() => {
|
|
58
|
+
if (hasContent && onContentClick) onContentClick(item);
|
|
59
|
+
if (onClick) onClick();
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
{item.label}
|
|
63
|
+
</button>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const isExternal = item.link_type === "external";
|
|
68
|
+
const href = isExternal
|
|
69
|
+
? item.external_url || undefined
|
|
70
|
+
: item.internal_page?.slug?.current
|
|
71
|
+
? `/${item.internal_page.slug.current}`
|
|
72
|
+
: undefined;
|
|
73
|
+
|
|
74
|
+
// Check if this link is the active page
|
|
75
|
+
const isActive =
|
|
76
|
+
!isExternal && href && (currentPath === href || currentPath.startsWith(`${href}/`));
|
|
77
|
+
|
|
78
|
+
const resolvedClassName = `${className}${isActive ? ` ${activeClassName}` : ""}`;
|
|
79
|
+
|
|
80
|
+
// No valid href — render as inert text
|
|
81
|
+
if (!href) {
|
|
82
|
+
return (
|
|
83
|
+
<span className={className} style={{ ...style, cursor: "default", opacity: 0.4 }}>
|
|
84
|
+
{item.label}
|
|
85
|
+
</span>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (isExternal) {
|
|
90
|
+
return (
|
|
91
|
+
<a
|
|
92
|
+
href={href}
|
|
93
|
+
target="_blank"
|
|
94
|
+
rel="noopener noreferrer"
|
|
95
|
+
className={resolvedClassName}
|
|
96
|
+
style={style}
|
|
97
|
+
onClick={onClick}
|
|
98
|
+
>
|
|
99
|
+
{item.label}
|
|
100
|
+
</a>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<Link href={href} className={resolvedClassName} style={style} onClick={onClick} aria-current={isActive ? "page" : undefined}>
|
|
106
|
+
{item.label}
|
|
107
|
+
</Link>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ============================================
|
|
112
|
+
// Props
|
|
113
|
+
// ============================================
|
|
114
|
+
|
|
115
|
+
interface NavbarProps {
|
|
116
|
+
navItems?: NavItem[];
|
|
117
|
+
design?: NavDesign;
|
|
118
|
+
/** @deprecated Use design.color instead */
|
|
119
|
+
colorVariant?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ============================================
|
|
123
|
+
// Component
|
|
124
|
+
// ============================================
|
|
125
|
+
|
|
126
|
+
export default function Navbar({
|
|
127
|
+
navItems = [],
|
|
128
|
+
design,
|
|
129
|
+
colorVariant = "yellow-lime",
|
|
130
|
+
}: NavbarProps) {
|
|
131
|
+
// Resolve color: context > design > prop
|
|
132
|
+
const { navColor: contextColor } = useNavColor();
|
|
133
|
+
const activeColor = contextColor || design?.color || colorVariant;
|
|
134
|
+
const pathname = usePathname();
|
|
135
|
+
const { isExiting } = usePageExit();
|
|
136
|
+
const { override: navAnimOverride } = useNavAnimation();
|
|
137
|
+
|
|
138
|
+
// ── Entrance animation resolution (page override > global) ──
|
|
139
|
+
const entranceDisabled = navAnimOverride.disabled === true;
|
|
140
|
+
const entrancePreset: NavEntrancePreset | "" = entranceDisabled
|
|
141
|
+
? ""
|
|
142
|
+
: (navAnimOverride.preset || design?.entrance_animation || "");
|
|
143
|
+
const entranceDuration = navAnimOverride.duration || design?.entrance_duration || 600;
|
|
144
|
+
const entranceDelay = navAnimOverride.delay ?? design?.entrance_delay ?? 0;
|
|
145
|
+
const entranceStagger = !entranceDisabled && (design?.entrance_stagger ?? false);
|
|
146
|
+
const entranceStaggerDelay = design?.entrance_stagger_delay ?? 80;
|
|
147
|
+
|
|
148
|
+
// Trigger entrance animation shortly after mount
|
|
149
|
+
const [navEntered, setNavEntered] = useState(!entrancePreset);
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (!entrancePreset) {
|
|
152
|
+
setNavEntered(true);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// Small delay to ensure the CSS initial hidden state paints first
|
|
156
|
+
const timer = requestAnimationFrame(() => setNavEntered(true));
|
|
157
|
+
return () => cancelAnimationFrame(timer);
|
|
158
|
+
}, [entrancePreset]);
|
|
159
|
+
|
|
160
|
+
// Resolve design values with defaults
|
|
161
|
+
const position = design?.position || "fixed";
|
|
162
|
+
const hideOnScroll = design?.hide_on_scroll !== false;
|
|
163
|
+
const fontSize = design?.font_size ?? 14;
|
|
164
|
+
const fontWeight = design?.font_weight || "400";
|
|
165
|
+
const fontFamily = design?.font_family || undefined;
|
|
166
|
+
const textTransformVal = design?.text_transform || "uppercase";
|
|
167
|
+
const textAlignVal = design?.text_align || "left";
|
|
168
|
+
const itemsJustify = textAlignVal === "center" ? "center" : textAlignVal === "right" ? "flex-end" : "flex-start";
|
|
169
|
+
const paddingH = design?.padding_h ?? 24;
|
|
170
|
+
const paddingV = design?.padding_v ?? 27;
|
|
171
|
+
const marginH = design?.margin_h ?? 0;
|
|
172
|
+
const marginV = design?.margin_v ?? 0;
|
|
173
|
+
const itemsGap = design?.items_gap ?? 32;
|
|
174
|
+
const bgColor = design?.background_color || "";
|
|
175
|
+
const bgOpacity = design?.background_opacity ?? 0;
|
|
176
|
+
const backdropBlur = !!design?.backdrop_blur;
|
|
177
|
+
const verticalAlign = design?.vertical_align || "top";
|
|
178
|
+
// Map vertical_align to CSS align-items value for the grid container
|
|
179
|
+
const gridAlignItems = verticalAlign === "bottom" ? "end" : verticalAlign === "middle" ? "center" : "start";
|
|
180
|
+
|
|
181
|
+
const [isVisible, setIsVisible] = useState(true);
|
|
182
|
+
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
|
183
|
+
const [lightboxItem, setLightboxItem] = useState<NavItem | null>(null);
|
|
184
|
+
const lastScrollY = useRef(0);
|
|
185
|
+
const ticking = useRef(false);
|
|
186
|
+
const hamburgerButtonRef = useRef<HTMLButtonElement>(null);
|
|
187
|
+
const mobileMenuRef = useRef<HTMLDivElement>(null);
|
|
188
|
+
|
|
189
|
+
// Close mobile menu on route change (back/forward navigation)
|
|
190
|
+
useEffect(() => {
|
|
191
|
+
setIsMenuOpen(false);
|
|
192
|
+
}, [pathname]);
|
|
193
|
+
|
|
194
|
+
// Close mobile menu immediately when page exit animations start
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
if (isExiting) setIsMenuOpen(false);
|
|
197
|
+
}, [isExiting]);
|
|
198
|
+
|
|
199
|
+
// Focus management: return focus to hamburger when menu closes
|
|
200
|
+
const prevMenuOpen = useRef(false);
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (!isMenuOpen && prevMenuOpen.current) {
|
|
203
|
+
hamburgerButtonRef.current?.focus();
|
|
204
|
+
}
|
|
205
|
+
prevMenuOpen.current = isMenuOpen;
|
|
206
|
+
}, [isMenuOpen]);
|
|
207
|
+
|
|
208
|
+
// Focus trap: handle Tab/Shift+Tab/Escape within mobile menu
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
if (!isMenuOpen || !mobileMenuRef.current) return;
|
|
211
|
+
|
|
212
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
213
|
+
if (e.key === "Escape") {
|
|
214
|
+
e.preventDefault();
|
|
215
|
+
setIsMenuOpen(false);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (e.key !== "Tab") return;
|
|
220
|
+
|
|
221
|
+
const focusable = mobileMenuRef.current?.querySelectorAll<HTMLElement>(
|
|
222
|
+
'a, button, [tabindex]:not([tabindex="-1"])'
|
|
223
|
+
);
|
|
224
|
+
if (!focusable || focusable.length === 0) return;
|
|
225
|
+
|
|
226
|
+
const first = focusable[0];
|
|
227
|
+
const last = focusable[focusable.length - 1];
|
|
228
|
+
|
|
229
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
230
|
+
e.preventDefault();
|
|
231
|
+
last.focus();
|
|
232
|
+
} else if (!e.shiftKey && document.activeElement === last) {
|
|
233
|
+
e.preventDefault();
|
|
234
|
+
first.focus();
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
239
|
+
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
240
|
+
}, [isMenuOpen]);
|
|
241
|
+
|
|
242
|
+
// Hide on scroll down, show on scroll up
|
|
243
|
+
const handleScroll = useCallback(() => {
|
|
244
|
+
if (!hideOnScroll) return;
|
|
245
|
+
if (ticking.current) return;
|
|
246
|
+
|
|
247
|
+
ticking.current = true;
|
|
248
|
+
requestAnimationFrame(() => {
|
|
249
|
+
const currentScrollY = window.scrollY;
|
|
250
|
+
|
|
251
|
+
if (currentScrollY < 10) {
|
|
252
|
+
setIsVisible(true);
|
|
253
|
+
} else if (currentScrollY > lastScrollY.current && currentScrollY > 80) {
|
|
254
|
+
setIsVisible(false);
|
|
255
|
+
} else if (currentScrollY < lastScrollY.current) {
|
|
256
|
+
setIsVisible(true);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
lastScrollY.current = currentScrollY;
|
|
260
|
+
ticking.current = false;
|
|
261
|
+
});
|
|
262
|
+
}, [hideOnScroll]);
|
|
263
|
+
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
if (position === "static") return;
|
|
266
|
+
window.addEventListener("scroll", handleScroll, { passive: true });
|
|
267
|
+
return () => window.removeEventListener("scroll", handleScroll);
|
|
268
|
+
}, [handleScroll, position]);
|
|
269
|
+
|
|
270
|
+
// Lock body scroll when mobile menu is open
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
if (isMenuOpen) {
|
|
273
|
+
document.body.style.overflow = "hidden";
|
|
274
|
+
} else {
|
|
275
|
+
document.body.style.overflow = "";
|
|
276
|
+
}
|
|
277
|
+
return () => {
|
|
278
|
+
document.body.style.overflow = "";
|
|
279
|
+
};
|
|
280
|
+
}, [isMenuOpen]);
|
|
281
|
+
|
|
282
|
+
// If activeColor is a hex string, use inline style; otherwise use Tailwind class
|
|
283
|
+
const isHexColor = activeColor.startsWith("#");
|
|
284
|
+
const textColorClass = isHexColor ? "" : (colorMap[activeColor] || colorMap["yellow-lime"]);
|
|
285
|
+
const textColorStyle: React.CSSProperties | undefined = isHexColor ? { color: activeColor } : undefined;
|
|
286
|
+
|
|
287
|
+
// Build background style
|
|
288
|
+
const navBgStyle: React.CSSProperties = {};
|
|
289
|
+
if (bgColor && bgOpacity > 0) {
|
|
290
|
+
const hex = bgColor.replace("#", "");
|
|
291
|
+
if (hex.length >= 6) {
|
|
292
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
293
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
294
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
295
|
+
navBgStyle.backgroundColor = `rgba(${r}, ${g}, ${b}, ${bgOpacity / 100})`;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (backdropBlur) {
|
|
299
|
+
navBgStyle.backdropFilter = "blur(12px)";
|
|
300
|
+
navBgStyle.WebkitBackdropFilter = "blur(12px)";
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Position class
|
|
304
|
+
const positionClass =
|
|
305
|
+
position === "fixed"
|
|
306
|
+
? "fixed"
|
|
307
|
+
: position === "sticky"
|
|
308
|
+
? "sticky"
|
|
309
|
+
: "relative";
|
|
310
|
+
|
|
311
|
+
// Visibility: only applies to fixed/sticky when hideOnScroll
|
|
312
|
+
const shouldHide = position !== "static" && hideOnScroll && !isVisible;
|
|
313
|
+
|
|
314
|
+
// Grid layout: detect new format (items with type field) vs legacy (logo in design)
|
|
315
|
+
const visibleItems = navItems.filter((item) => item.visible !== false);
|
|
316
|
+
const hasNewFormat = visibleItems.some((i) => i.type === "logo" || i.type === "menu-item");
|
|
317
|
+
|
|
318
|
+
// New format: logo is a nav item; legacy: logo from design
|
|
319
|
+
const logoItem = hasNewFormat ? visibleItems.find((i) => i.type === "logo") : null;
|
|
320
|
+
// Filter out ALL logo-type items from menu items (prevents duplicate rendering)
|
|
321
|
+
const menuItems = hasNewFormat
|
|
322
|
+
? visibleItems.filter((i) => i.type !== "logo")
|
|
323
|
+
: visibleItems;
|
|
324
|
+
const logoCols = logoItem ? logoItem.column_span : (design?.logo_columns ?? 3);
|
|
325
|
+
const logoLabel = logoItem?.label || design?.logo_text || getSiteConfig().defaults.logoText;
|
|
326
|
+
|
|
327
|
+
const placedItems = menuItems.filter((i) => typeof i.grid_column === "number" && i.grid_column > 0);
|
|
328
|
+
const unplacedItems = menuItems.filter((i) => typeof i.grid_column !== "number" || i.grid_column <= 0);
|
|
329
|
+
|
|
330
|
+
// Shared link styling (textTransform + fontFamily applied via inline style)
|
|
331
|
+
// When using hex color, we must set `color: inherit` on links because browser
|
|
332
|
+
// user-agent stylesheet sets `a { color: ... }` which overrides CSS inheritance.
|
|
333
|
+
const linkClassName = `tracking-normal ${textColorClass} transition-colors duration-200 hover:opacity-80 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current`;
|
|
334
|
+
const linkStyle: React.CSSProperties = {
|
|
335
|
+
fontSize: `${fontSize}px`,
|
|
336
|
+
fontWeight: fontWeight as React.CSSProperties["fontWeight"],
|
|
337
|
+
textTransform: textTransformVal as React.CSSProperties["textTransform"],
|
|
338
|
+
fontFamily: fontFamily || "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace)",
|
|
339
|
+
...(isHexColor ? { color: "inherit" } : {}),
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
return (
|
|
343
|
+
<>
|
|
344
|
+
{/* Navbar */}
|
|
345
|
+
<nav
|
|
346
|
+
role="navigation"
|
|
347
|
+
aria-label="Main navigation"
|
|
348
|
+
className={`${positionClass} top-0 left-0 right-0 z-50 transition-transform duration-300 ease-in-out ${
|
|
349
|
+
shouldHide ? "-translate-y-full" : "translate-y-0"
|
|
350
|
+
}`}
|
|
351
|
+
// Nav entrance animation (whole nav when no stagger)
|
|
352
|
+
{...(entrancePreset && !entranceStagger ? { "data-nav-entrance": entrancePreset } : {})}
|
|
353
|
+
{...(navEntered && entrancePreset && !entranceStagger ? { "data-nav-entered": "" } : {})}
|
|
354
|
+
style={{
|
|
355
|
+
...navBgStyle,
|
|
356
|
+
...textColorStyle,
|
|
357
|
+
...(marginH > 0 || marginV > 0
|
|
358
|
+
? {
|
|
359
|
+
left: `${marginH}px`,
|
|
360
|
+
right: `${marginH}px`,
|
|
361
|
+
top: `${marginV}px`,
|
|
362
|
+
borderRadius: "8px",
|
|
363
|
+
}
|
|
364
|
+
: {}),
|
|
365
|
+
// CSS custom properties for animation duration
|
|
366
|
+
...(entrancePreset && !entranceStagger ? {
|
|
367
|
+
"--nav-entrance-duration": `${entranceDuration}ms`,
|
|
368
|
+
"--nav-entrance-delay": `${entranceDelay}ms`,
|
|
369
|
+
} as React.CSSProperties : {}),
|
|
370
|
+
}}
|
|
371
|
+
>
|
|
372
|
+
{/* Desktop: 12-column grid constrained to --grid-width */}
|
|
373
|
+
<div
|
|
374
|
+
className="hidden lg:grid"
|
|
375
|
+
style={{
|
|
376
|
+
gridTemplateColumns: "repeat(12, 1fr)",
|
|
377
|
+
maxWidth: "var(--grid-width, 1445px)",
|
|
378
|
+
marginLeft: "auto",
|
|
379
|
+
marginRight: "auto",
|
|
380
|
+
paddingLeft: `${paddingH}px`,
|
|
381
|
+
paddingRight: `${paddingH}px`,
|
|
382
|
+
paddingTop: `${paddingV}px`,
|
|
383
|
+
paddingBottom: `${paddingV}px`,
|
|
384
|
+
alignItems: gridAlignItems,
|
|
385
|
+
columnGap: `${itemsGap}px`,
|
|
386
|
+
}}
|
|
387
|
+
>
|
|
388
|
+
{/* Logo */}
|
|
389
|
+
<div
|
|
390
|
+
{...(entrancePreset && entranceStagger ? { "data-nav-item-entrance": entrancePreset } : {})}
|
|
391
|
+
{...(navEntered && entrancePreset && entranceStagger ? { "data-nav-entered": "" } : {})}
|
|
392
|
+
style={{
|
|
393
|
+
gridColumn: logoItem ? `${logoItem.grid_column} / span ${logoItem.column_span}` : `1 / span ${logoCols}`,
|
|
394
|
+
gridRow: 1,
|
|
395
|
+
display: "flex",
|
|
396
|
+
justifyContent: "flex-start",
|
|
397
|
+
alignItems: "center",
|
|
398
|
+
minWidth: 0,
|
|
399
|
+
overflow: "hidden",
|
|
400
|
+
whiteSpace: "nowrap",
|
|
401
|
+
...(logoItem?.style_overrides?.vertical_align ? {
|
|
402
|
+
alignSelf: logoItem.style_overrides.vertical_align === "bottom" ? "end" : logoItem.style_overrides.vertical_align === "middle" ? "center" : "start",
|
|
403
|
+
} : {}),
|
|
404
|
+
...(entrancePreset && entranceStagger ? {
|
|
405
|
+
"--nav-item-duration": `${Math.round(entranceDuration * 0.7)}ms`,
|
|
406
|
+
"--nav-item-delay": `${entranceDelay}ms`,
|
|
407
|
+
} as React.CSSProperties : {}),
|
|
408
|
+
}}
|
|
409
|
+
>
|
|
410
|
+
<Link
|
|
411
|
+
href="/"
|
|
412
|
+
className={linkClassName}
|
|
413
|
+
style={{
|
|
414
|
+
...linkStyle,
|
|
415
|
+
...(logoItem?.style_overrides?.font_size ? { fontSize: `${logoItem.style_overrides.font_size}px` } : {}),
|
|
416
|
+
...(logoItem?.style_overrides?.font_weight ? { fontWeight: logoItem.style_overrides.font_weight } : {}),
|
|
417
|
+
...(logoItem?.style_overrides?.font_family ? { fontFamily: logoItem.style_overrides.font_family } : {}),
|
|
418
|
+
...(logoItem?.style_overrides?.color ? { color: logoItem.style_overrides.color } : {}),
|
|
419
|
+
...(logoItem?.style_overrides?.text_transform ? { textTransform: logoItem.style_overrides.text_transform as React.CSSProperties["textTransform"] } : {}),
|
|
420
|
+
}}
|
|
421
|
+
>
|
|
422
|
+
{logoLabel}
|
|
423
|
+
</Link>
|
|
424
|
+
</div>
|
|
425
|
+
|
|
426
|
+
{/* Items with grid_column — each in its own column(s) */}
|
|
427
|
+
{placedItems.map((item, idx) => {
|
|
428
|
+
const itemVAlign = item.style_overrides?.vertical_align;
|
|
429
|
+
// Stagger: logo is index 0, first menu item is index 1, etc.
|
|
430
|
+
const staggerIdx = idx + 1;
|
|
431
|
+
return (
|
|
432
|
+
<div
|
|
433
|
+
key={item._key}
|
|
434
|
+
{...(entrancePreset && entranceStagger ? { "data-nav-item-entrance": entrancePreset } : {})}
|
|
435
|
+
{...(navEntered && entrancePreset && entranceStagger ? { "data-nav-entered": "" } : {})}
|
|
436
|
+
style={{
|
|
437
|
+
gridColumn: `${item.grid_column} / span ${item.column_span || 1}`,
|
|
438
|
+
gridRow: 1,
|
|
439
|
+
display: "flex",
|
|
440
|
+
justifyContent: itemsJustify,
|
|
441
|
+
alignItems: "center",
|
|
442
|
+
minWidth: 0,
|
|
443
|
+
overflow: "hidden",
|
|
444
|
+
whiteSpace: "nowrap",
|
|
445
|
+
...(itemVAlign ? {
|
|
446
|
+
alignSelf: itemVAlign === "bottom" ? "end" : itemVAlign === "middle" ? "center" : "start",
|
|
447
|
+
} : {}),
|
|
448
|
+
...(entrancePreset && entranceStagger ? {
|
|
449
|
+
"--nav-item-duration": `${Math.round(entranceDuration * 0.7)}ms`,
|
|
450
|
+
"--nav-item-delay": `${entranceDelay + staggerIdx * entranceStaggerDelay}ms`,
|
|
451
|
+
} as React.CSSProperties : {}),
|
|
452
|
+
}}
|
|
453
|
+
>
|
|
454
|
+
<NavLink
|
|
455
|
+
item={item}
|
|
456
|
+
className={linkClassName}
|
|
457
|
+
style={{
|
|
458
|
+
...linkStyle,
|
|
459
|
+
...(item.style_overrides?.font_size ? { fontSize: `${item.style_overrides.font_size}px` } : {}),
|
|
460
|
+
...(item.style_overrides?.font_weight ? { fontWeight: item.style_overrides.font_weight } : {}),
|
|
461
|
+
...(item.style_overrides?.font_family ? { fontFamily: item.style_overrides.font_family } : {}),
|
|
462
|
+
...(item.style_overrides?.color ? { color: item.style_overrides.color } : {}),
|
|
463
|
+
...(item.style_overrides?.text_transform ? { textTransform: item.style_overrides.text_transform as React.CSSProperties["textTransform"] } : {}),
|
|
464
|
+
}}
|
|
465
|
+
currentPath={pathname}
|
|
466
|
+
onContentClick={setLightboxItem}
|
|
467
|
+
/>
|
|
468
|
+
</div>
|
|
469
|
+
);
|
|
470
|
+
})}
|
|
471
|
+
|
|
472
|
+
{/* Fallback: unplaced items in remaining columns */}
|
|
473
|
+
{unplacedItems.length > 0 && (
|
|
474
|
+
<div
|
|
475
|
+
style={{
|
|
476
|
+
gridColumn: `${logoCols + 1} / -1`,
|
|
477
|
+
display: "flex",
|
|
478
|
+
justifyContent: itemsJustify,
|
|
479
|
+
alignItems: "center",
|
|
480
|
+
gap: `${design?.items_gap ?? 32}px`,
|
|
481
|
+
}}
|
|
482
|
+
>
|
|
483
|
+
{unplacedItems.map((item) => (
|
|
484
|
+
<NavLink
|
|
485
|
+
key={item._key}
|
|
486
|
+
item={item}
|
|
487
|
+
className={linkClassName}
|
|
488
|
+
style={linkStyle}
|
|
489
|
+
currentPath={pathname}
|
|
490
|
+
/>
|
|
491
|
+
))}
|
|
492
|
+
</div>
|
|
493
|
+
)}
|
|
494
|
+
</div>
|
|
495
|
+
|
|
496
|
+
{/* Mobile: simple flex layout with hamburger */}
|
|
497
|
+
<div
|
|
498
|
+
className="flex lg:hidden items-center justify-between"
|
|
499
|
+
style={{
|
|
500
|
+
maxWidth: "var(--grid-width, 1445px)",
|
|
501
|
+
marginLeft: "auto",
|
|
502
|
+
marginRight: "auto",
|
|
503
|
+
paddingLeft: `${paddingH}px`,
|
|
504
|
+
paddingRight: `${paddingH}px`,
|
|
505
|
+
paddingTop: `${paddingV}px`,
|
|
506
|
+
paddingBottom: `${paddingV}px`,
|
|
507
|
+
}}
|
|
508
|
+
>
|
|
509
|
+
<Link
|
|
510
|
+
href="/"
|
|
511
|
+
className={linkClassName}
|
|
512
|
+
style={linkStyle}
|
|
513
|
+
>
|
|
514
|
+
{logoLabel}
|
|
515
|
+
</Link>
|
|
516
|
+
|
|
517
|
+
<button
|
|
518
|
+
ref={hamburgerButtonRef}
|
|
519
|
+
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
|
520
|
+
className={`flex flex-col justify-center items-center gap-[5px] w-8 h-8 ${textColorClass} focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current`}
|
|
521
|
+
aria-label={isMenuOpen ? "Close menu" : "Open menu"}
|
|
522
|
+
aria-expanded={isMenuOpen}
|
|
523
|
+
aria-controls="mobile-nav-menu"
|
|
524
|
+
>
|
|
525
|
+
<span
|
|
526
|
+
className={`block w-5 h-[1.5px] bg-current transition-all duration-300 ${
|
|
527
|
+
isMenuOpen ? "rotate-45 translate-y-[6.5px]" : ""
|
|
528
|
+
}`}
|
|
529
|
+
/>
|
|
530
|
+
<span
|
|
531
|
+
className={`block w-5 h-[1.5px] bg-current transition-all duration-300 ${
|
|
532
|
+
isMenuOpen ? "opacity-0" : ""
|
|
533
|
+
}`}
|
|
534
|
+
/>
|
|
535
|
+
<span
|
|
536
|
+
className={`block w-5 h-[1.5px] bg-current transition-all duration-300 ${
|
|
537
|
+
isMenuOpen ? "-rotate-45 -translate-y-[6.5px]" : ""
|
|
538
|
+
}`}
|
|
539
|
+
/>
|
|
540
|
+
</button>
|
|
541
|
+
</div>
|
|
542
|
+
</nav>
|
|
543
|
+
|
|
544
|
+
{/* Mobile overlay menu */}
|
|
545
|
+
<div
|
|
546
|
+
ref={mobileMenuRef}
|
|
547
|
+
id="mobile-nav-menu"
|
|
548
|
+
role="dialog"
|
|
549
|
+
aria-modal={isMenuOpen}
|
|
550
|
+
aria-label="Mobile navigation menu"
|
|
551
|
+
className={`fixed inset-0 z-40 bg-brand-dark transition-opacity duration-300 lg:hidden ${
|
|
552
|
+
isMenuOpen
|
|
553
|
+
? "opacity-100 pointer-events-auto"
|
|
554
|
+
: "opacity-0 pointer-events-none"
|
|
555
|
+
}`}
|
|
556
|
+
>
|
|
557
|
+
<div className="flex flex-col items-center justify-center h-full gap-8">
|
|
558
|
+
{[...menuItems].sort((a, b) => (a.grid_column || 0) - (b.grid_column || 0)).map((item) => (
|
|
559
|
+
<NavLink
|
|
560
|
+
key={item._key}
|
|
561
|
+
item={item}
|
|
562
|
+
className={`font-mono text-2xl tracking-wide ${textColorClass} transition-colors duration-200 hover:opacity-80 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current`}
|
|
563
|
+
style={{ textTransform: textTransformVal as React.CSSProperties["textTransform"], ...(fontFamily ? { fontFamily } : {}), ...(isHexColor ? { color: "inherit" } : {}) }}
|
|
564
|
+
currentPath={pathname}
|
|
565
|
+
onContentClick={(clickedItem) => { setLightboxItem(clickedItem); setIsMenuOpen(false); }}
|
|
566
|
+
/>
|
|
567
|
+
))}
|
|
568
|
+
</div>
|
|
569
|
+
</div>
|
|
570
|
+
|
|
571
|
+
{/* Content lightbox */}
|
|
572
|
+
{lightboxItem && lightboxItem.link_type === "content" && (
|
|
573
|
+
<NavContentLightbox
|
|
574
|
+
contentType={lightboxItem.content_type || "image"}
|
|
575
|
+
contentAsset={lightboxItem.content_asset}
|
|
576
|
+
contentUrl={lightboxItem.content_url}
|
|
577
|
+
onClose={() => setLightboxItem(null)}
|
|
578
|
+
/>
|
|
579
|
+
)}
|
|
580
|
+
</>
|
|
581
|
+
);
|
|
582
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PortfolioTracker — Outreach & campaign visit tracking.
|
|
5
|
+
*
|
|
6
|
+
* Tracks two types of visits:
|
|
7
|
+
* 1. Lead referrals: example.com/portfolio?r=AM92
|
|
8
|
+
* 2. Custom channels: example.com/portfolio?c=discord
|
|
9
|
+
*
|
|
10
|
+
* Sends a POST to the Knock API on first load and on SPA route changes.
|
|
11
|
+
* Uses sessionStorage to persist ref/channel across internal navigation.
|
|
12
|
+
*
|
|
13
|
+
* Configured via site.config.ts: tracking.knockApiUrl + tracking.sessionPrefix.
|
|
14
|
+
* If knockApiUrl is empty, the component renders nothing.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { useEffect, useRef } from "react";
|
|
18
|
+
import { usePathname } from "next/navigation";
|
|
19
|
+
import { getSiteConfig } from "../../lib/config";
|
|
20
|
+
|
|
21
|
+
const cfg = getSiteConfig();
|
|
22
|
+
const KNOCK_API_URL = cfg.tracking.knockApiUrl;
|
|
23
|
+
const REF_KEY = `${cfg.tracking.sessionPrefix}_ref`;
|
|
24
|
+
const CHANNEL_KEY = `${cfg.tracking.sessionPrefix}_channel`;
|
|
25
|
+
|
|
26
|
+
function sendVisit(
|
|
27
|
+
page: string,
|
|
28
|
+
ref: string | null,
|
|
29
|
+
channel: string | null,
|
|
30
|
+
): void {
|
|
31
|
+
if (!ref && !channel) return;
|
|
32
|
+
|
|
33
|
+
const body: Record<string, string> = {
|
|
34
|
+
page,
|
|
35
|
+
timestamp: new Date().toISOString(),
|
|
36
|
+
};
|
|
37
|
+
if (ref) body.ref = ref;
|
|
38
|
+
if (channel) body.channel = channel;
|
|
39
|
+
|
|
40
|
+
fetch(KNOCK_API_URL, {
|
|
41
|
+
method: "POST",
|
|
42
|
+
headers: { "Content-Type": "application/json" },
|
|
43
|
+
body: JSON.stringify(body),
|
|
44
|
+
}).catch(() => {
|
|
45
|
+
/* silent — tracking should never break the site */
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default function PortfolioTracker() {
|
|
50
|
+
const pathname = usePathname();
|
|
51
|
+
const prevPathRef = useRef<string | null>(null);
|
|
52
|
+
const enabled = !!KNOCK_API_URL;
|
|
53
|
+
|
|
54
|
+
// Initial visit + capture URL params into sessionStorage
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
if (!enabled) return;
|
|
57
|
+
|
|
58
|
+
const params = new URLSearchParams(window.location.search);
|
|
59
|
+
const ref = params.get("r") || params.get("ref");
|
|
60
|
+
const channel = params.get("c");
|
|
61
|
+
|
|
62
|
+
if (ref) sessionStorage.setItem(REF_KEY, ref);
|
|
63
|
+
if (channel) sessionStorage.setItem(CHANNEL_KEY, channel);
|
|
64
|
+
|
|
65
|
+
const storedRef = ref || sessionStorage.getItem(REF_KEY);
|
|
66
|
+
const storedChannel = channel || sessionStorage.getItem(CHANNEL_KEY);
|
|
67
|
+
|
|
68
|
+
if (storedRef || storedChannel) {
|
|
69
|
+
sendVisit(window.location.pathname, storedRef, storedChannel);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
prevPathRef.current = window.location.pathname;
|
|
73
|
+
}, [enabled]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
74
|
+
|
|
75
|
+
// SPA route change tracking (Next.js App Router)
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (!enabled) return;
|
|
78
|
+
if (prevPathRef.current === null || pathname === prevPathRef.current) return;
|
|
79
|
+
prevPathRef.current = pathname;
|
|
80
|
+
|
|
81
|
+
const ref = sessionStorage.getItem(REF_KEY);
|
|
82
|
+
const channel = sessionStorage.getItem(CHANNEL_KEY);
|
|
83
|
+
sendVisit(pathname, ref, channel);
|
|
84
|
+
}, [pathname, enabled]);
|
|
85
|
+
|
|
86
|
+
return null;
|
|
87
|
+
}
|