@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,142 @@
|
|
|
1
|
+
import type { Page, ContentBlock, ContentItem, PageSection, PageSectionV2, SectionColumn, CustomSectionInstance, ParallaxGroup } from "../../lib/sanity/types";
|
|
2
|
+
import { isPageSection, isPageSectionV2, isCustomSectionInstance, isParallaxGroup } from "../../lib/sanity/types";
|
|
3
|
+
import SectionRenderer from "./SectionRenderer";
|
|
4
|
+
import SectionV2Renderer from "./SectionV2Renderer";
|
|
5
|
+
import CustomSectionInstanceRenderer from "./CustomSectionInstanceRenderer";
|
|
6
|
+
import ParallaxGroupRenderer from "./ParallaxGroupRenderer";
|
|
7
|
+
import { PageNavColor } from "./PageNavColor";
|
|
8
|
+
import { PageNavAnimation } from "./PageNavAnimation";
|
|
9
|
+
import { PageBackground } from "./PageBackground";
|
|
10
|
+
import { assetUrl } from "../../lib/assets";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Find the first image-bearing block (CoverBlock or ImageBlock) in the page.
|
|
14
|
+
* Used to inject <link rel="preload"> for the above-the-fold hero image,
|
|
15
|
+
* reducing LCP by 200–500ms on mobile.
|
|
16
|
+
*/
|
|
17
|
+
/** Check a single block for an image/cover path */
|
|
18
|
+
function getBlockImagePath(block: ContentBlock): string | null {
|
|
19
|
+
if (block._type === "coverBlock") {
|
|
20
|
+
const cover = block as Extract<ContentBlock, { _type: "coverBlock" }>;
|
|
21
|
+
if (cover.media_type !== "video" && cover.media_path) {
|
|
22
|
+
return cover.media_path;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (block._type === "imageBlock") {
|
|
26
|
+
const img = block as Extract<ContentBlock, { _type: "imageBlock" }>;
|
|
27
|
+
if (img.asset_path) {
|
|
28
|
+
return img.asset_path;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function findFirstImagePath(items: ContentItem[]): string | null {
|
|
35
|
+
for (const item of items) {
|
|
36
|
+
if (isPageSection(item)) {
|
|
37
|
+
// Check inside PageSection blocks for image/cover blocks
|
|
38
|
+
const section = item as PageSection;
|
|
39
|
+
const sectionBlock = Array.isArray(section.block) ? section.block[0] : undefined;
|
|
40
|
+
if (sectionBlock) {
|
|
41
|
+
const path = getBlockImagePath(sectionBlock as unknown as ContentBlock);
|
|
42
|
+
if (path) return path;
|
|
43
|
+
}
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (isParallaxGroup(item)) {
|
|
47
|
+
// Check first slide's background image for preloading
|
|
48
|
+
const group = item as ParallaxGroup;
|
|
49
|
+
const firstSlide = group.slides?.[0];
|
|
50
|
+
if (firstSlide?.background_type !== "video" && firstSlide?.background_image) {
|
|
51
|
+
return firstSlide.background_image;
|
|
52
|
+
}
|
|
53
|
+
// Also check first slide's content blocks
|
|
54
|
+
if (firstSlide?.columns) {
|
|
55
|
+
for (const col of firstSlide.columns) {
|
|
56
|
+
for (const block of col.blocks || []) {
|
|
57
|
+
const path = getBlockImagePath(block);
|
|
58
|
+
if (path) return path;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
if (isPageSectionV2(item)) {
|
|
65
|
+
// Check inside V2 section columns for image/cover blocks
|
|
66
|
+
const section = item as PageSectionV2;
|
|
67
|
+
for (const col of section.columns || []) {
|
|
68
|
+
for (const block of col.blocks || []) {
|
|
69
|
+
const path = getBlockImagePath(block);
|
|
70
|
+
if (path) return path;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
// Only check the first content item — above-the-fold
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export default function PageRenderer({ page }: { page: Page }) {
|
|
81
|
+
if (!page.content_rows?.length) {
|
|
82
|
+
return (
|
|
83
|
+
<div className="flex min-h-[50vh] items-center justify-center">
|
|
84
|
+
<p className="font-mono text-sm text-brand-muted">
|
|
85
|
+
This page has no content yet.
|
|
86
|
+
</p>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Find the first image-bearing block for preloading
|
|
92
|
+
const preloadPath = findFirstImagePath(page.content_rows);
|
|
93
|
+
|
|
94
|
+
// Apply page-level settings (background color, text color)
|
|
95
|
+
const ps = page.page_settings;
|
|
96
|
+
const pageStyle: React.CSSProperties = {};
|
|
97
|
+
if (ps?.background_color && ps.background_color !== "transparent") {
|
|
98
|
+
pageStyle.backgroundColor = ps.background_color;
|
|
99
|
+
}
|
|
100
|
+
if (ps?.text_color) {
|
|
101
|
+
pageStyle.color = ps.text_color;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Page-level enter animation config (passed as prop to section renderers)
|
|
105
|
+
const pageEnterAnimation = ps?.enter_animation;
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<>
|
|
109
|
+
{/* Preload above-the-fold image to reduce LCP */}
|
|
110
|
+
{preloadPath && (
|
|
111
|
+
<link
|
|
112
|
+
rel="preload"
|
|
113
|
+
as="image"
|
|
114
|
+
href={assetUrl(preloadPath)}
|
|
115
|
+
/>
|
|
116
|
+
)}
|
|
117
|
+
<article style={Object.keys(pageStyle).length > 0 ? pageStyle : undefined}>
|
|
118
|
+
{ps?.nav_color && <PageNavColor color={ps.nav_color} />}
|
|
119
|
+
{(ps?.nav_entrance_animation || ps?.nav_entrance_duration || ps?.nav_entrance_delay || ps?.nav_entrance_disabled) && (
|
|
120
|
+
<PageNavAnimation
|
|
121
|
+
preset={ps.nav_entrance_animation}
|
|
122
|
+
duration={ps.nav_entrance_duration}
|
|
123
|
+
delay={ps.nav_entrance_delay}
|
|
124
|
+
disabled={ps.nav_entrance_disabled}
|
|
125
|
+
/>
|
|
126
|
+
)}
|
|
127
|
+
<PageBackground color={ps?.background_color} />
|
|
128
|
+
{page.content_rows.map((item) =>
|
|
129
|
+
isCustomSectionInstance(item)
|
|
130
|
+
? <CustomSectionInstanceRenderer key={item._key} instance={item as CustomSectionInstance} />
|
|
131
|
+
: isParallaxGroup(item)
|
|
132
|
+
? <ParallaxGroupRenderer key={item._key} group={item as ParallaxGroup} pageEnterAnimation={pageEnterAnimation} />
|
|
133
|
+
: isPageSectionV2(item)
|
|
134
|
+
? <SectionV2Renderer key={item._key} section={item as PageSectionV2} pageEnterAnimation={pageEnterAnimation} />
|
|
135
|
+
: isPageSection(item)
|
|
136
|
+
? <SectionRenderer key={item._key} section={item as PageSection} pageEnterAnimation={pageEnterAnimation} />
|
|
137
|
+
: null
|
|
138
|
+
)}
|
|
139
|
+
</article>
|
|
140
|
+
</>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ParallaxGroupRenderer — renders a group of parallax slides on the public site.
|
|
5
|
+
*
|
|
6
|
+
* Features:
|
|
7
|
+
* - Parallax scroll engine (background at 40% of scroll speed, GPU-composited)
|
|
8
|
+
* - Three transition effects: parallax (default), crossfade, reveal
|
|
9
|
+
* - JS-based snap behavior (not CSS scroll-snap — avoids page scroll conflicts)
|
|
10
|
+
* - Mobile: parallax disabled on touch devices, static backgrounds
|
|
11
|
+
* - Respects prefers-reduced-motion
|
|
12
|
+
* - Page exit animations (PageExitContext) work correctly
|
|
13
|
+
* - Enter animations work on slide content (via SectionV2Renderer)
|
|
14
|
+
*
|
|
15
|
+
* Session 126: Created as part of Parallax V2 Phase 4 (Public Site Renderer).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useRef, useEffect, useCallback } from "react";
|
|
19
|
+
import type { ParallaxGroup } from "../../lib/sanity/types";
|
|
20
|
+
import type { EnterAnimationConfig } from "../../lib/animation/enter-types";
|
|
21
|
+
import { useNavColor } from "../../lib/contexts/NavColorContext";
|
|
22
|
+
import { lerpHex, isValidHex } from "../../lib/color-utils";
|
|
23
|
+
import ParallaxSlideRenderer from "./ParallaxSlideRenderer";
|
|
24
|
+
|
|
25
|
+
// ── Constants ──
|
|
26
|
+
|
|
27
|
+
/** Default parallax intensity — background moves at 40% of scroll speed */
|
|
28
|
+
const DEFAULT_PARALLAX_INTENSITY = 0.4;
|
|
29
|
+
|
|
30
|
+
/** Snap zone: if a slide boundary is within ±15vh of viewport top, snap to it */
|
|
31
|
+
const SNAP_ZONE_VH = 0.15;
|
|
32
|
+
|
|
33
|
+
/** Debounce time for snap after scroll stops (ms) */
|
|
34
|
+
const SNAP_IDLE_MS = 150;
|
|
35
|
+
|
|
36
|
+
/** Minimum percentage of viewport the group must occupy to enable snap */
|
|
37
|
+
const SNAP_MIN_VIEWPORT_RATIO = 0.6;
|
|
38
|
+
|
|
39
|
+
// ── Types ──
|
|
40
|
+
|
|
41
|
+
interface SlideRefs {
|
|
42
|
+
slide: HTMLElement | null;
|
|
43
|
+
bg: HTMLDivElement | HTMLVideoElement | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface ParallaxGroupRendererProps {
|
|
47
|
+
group: ParallaxGroup;
|
|
48
|
+
/** Page-level enter animation (passed through to slides) */
|
|
49
|
+
pageEnterAnimation?: EnterAnimationConfig;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Helpers ──
|
|
53
|
+
|
|
54
|
+
/** Detect touch device */
|
|
55
|
+
function isTouchDevice(): boolean {
|
|
56
|
+
if (typeof window === "undefined") return false;
|
|
57
|
+
return "ontouchstart" in window || navigator.maxTouchPoints > 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Check prefers-reduced-motion */
|
|
61
|
+
function prefersReducedMotion(): boolean {
|
|
62
|
+
if (typeof window === "undefined") return false;
|
|
63
|
+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Component ──
|
|
67
|
+
|
|
68
|
+
export default function ParallaxGroupRenderer({
|
|
69
|
+
group,
|
|
70
|
+
pageEnterAnimation,
|
|
71
|
+
}: ParallaxGroupRendererProps) {
|
|
72
|
+
const groupRef = useRef<HTMLDivElement>(null);
|
|
73
|
+
const slideRefsMap = useRef<Map<string, SlideRefs>>(new Map());
|
|
74
|
+
const rafId = useRef<number>(0);
|
|
75
|
+
const snapTimeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
76
|
+
const snapResetTimeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
77
|
+
const isSnapping = useRef(false);
|
|
78
|
+
const isPointerDown = useRef(false);
|
|
79
|
+
const disableParallax = useRef(false);
|
|
80
|
+
|
|
81
|
+
const { setNavColor } = useNavColor();
|
|
82
|
+
/** Store the page-level nav color so we can restore it on unmount / when scrolling out */
|
|
83
|
+
const pageNavColorRef = useRef<string>("");
|
|
84
|
+
|
|
85
|
+
const effect = group.transition_effect || "parallax";
|
|
86
|
+
const snapEnabled = group.snap_enabled !== false;
|
|
87
|
+
const parallaxIntensity = group.parallax_intensity ?? DEFAULT_PARALLAX_INTENSITY;
|
|
88
|
+
|
|
89
|
+
// ── Build slide nav_color lookup (ordered, for interpolation) ──
|
|
90
|
+
const slideNavColors = (group.slides || []).map((s) => s.nav_color || "");
|
|
91
|
+
|
|
92
|
+
// ── Slide ref callbacks ──
|
|
93
|
+
|
|
94
|
+
const getSlideRef = useCallback((key: string) => {
|
|
95
|
+
return (el: HTMLElement | null) => {
|
|
96
|
+
const entry = slideRefsMap.current.get(key) || { slide: null, bg: null };
|
|
97
|
+
entry.slide = el;
|
|
98
|
+
slideRefsMap.current.set(key, entry);
|
|
99
|
+
};
|
|
100
|
+
}, []);
|
|
101
|
+
|
|
102
|
+
const getBgRef = useCallback((key: string) => {
|
|
103
|
+
return (el: HTMLDivElement | HTMLVideoElement | null) => {
|
|
104
|
+
const entry = slideRefsMap.current.get(key) || { slide: null, bg: null };
|
|
105
|
+
entry.bg = el;
|
|
106
|
+
slideRefsMap.current.set(key, entry);
|
|
107
|
+
};
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
// ── Nav color interpolation helper ──
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Compute the interpolated navbar color based on current scroll position.
|
|
114
|
+
* Uses slide centers: the viewport center's position between two adjacent
|
|
115
|
+
* slide centers determines the interpolation factor.
|
|
116
|
+
*/
|
|
117
|
+
const computeNavColor = useCallback((scrollY: number, vh: number): string | null => {
|
|
118
|
+
const slideKeys = Array.from(slideRefsMap.current.keys());
|
|
119
|
+
if (slideKeys.length === 0) return null;
|
|
120
|
+
|
|
121
|
+
// Check if any slide has a nav_color set at all
|
|
122
|
+
const hasAnyNavColor = slideNavColors.some((c) => c && isValidHex(c));
|
|
123
|
+
if (!hasAnyNavColor) return null;
|
|
124
|
+
|
|
125
|
+
const viewCenter = scrollY + vh / 2;
|
|
126
|
+
|
|
127
|
+
// Build ordered array of {center, color} for each slide
|
|
128
|
+
const slideData: { center: number; color: string }[] = [];
|
|
129
|
+
const orderedSlides = group.slides || [];
|
|
130
|
+
for (let i = 0; i < orderedSlides.length; i++) {
|
|
131
|
+
const refs = slideRefsMap.current.get(orderedSlides[i]._key);
|
|
132
|
+
if (!refs?.slide) continue;
|
|
133
|
+
const el = refs.slide;
|
|
134
|
+
const center = el.offsetTop + el.offsetHeight / 2;
|
|
135
|
+
slideData.push({ center, color: slideNavColors[i] || "" });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (slideData.length === 0) return null;
|
|
139
|
+
|
|
140
|
+
// If viewport center is before the first slide center, use first slide's color
|
|
141
|
+
if (viewCenter <= slideData[0].center) {
|
|
142
|
+
return slideData[0].color && isValidHex(slideData[0].color) ? slideData[0].color : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// If viewport center is after the last slide center, use last slide's color
|
|
146
|
+
const last = slideData[slideData.length - 1];
|
|
147
|
+
if (viewCenter >= last.center) {
|
|
148
|
+
return last.color && isValidHex(last.color) ? last.color : null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Find which two slides the viewport center is between
|
|
152
|
+
for (let i = 0; i < slideData.length - 1; i++) {
|
|
153
|
+
const a = slideData[i];
|
|
154
|
+
const b = slideData[i + 1];
|
|
155
|
+
if (viewCenter >= a.center && viewCenter <= b.center) {
|
|
156
|
+
const t = (viewCenter - a.center) / (b.center - a.center);
|
|
157
|
+
|
|
158
|
+
const colorA = a.color && isValidHex(a.color) ? a.color : null;
|
|
159
|
+
const colorB = b.color && isValidHex(b.color) ? b.color : null;
|
|
160
|
+
|
|
161
|
+
if (colorA && colorB) {
|
|
162
|
+
return lerpHex(colorA, colorB, t);
|
|
163
|
+
}
|
|
164
|
+
// One side has color, the other doesn't — use the one that has it
|
|
165
|
+
// with a fade: if approaching the no-color slide, we fade back to page color
|
|
166
|
+
if (colorA && !colorB) return colorA;
|
|
167
|
+
if (!colorA && colorB) return colorB;
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return null;
|
|
173
|
+
}, [slideNavColors, group.slides]);
|
|
174
|
+
|
|
175
|
+
// ── Capture page-level nav color on mount, restore on unmount ──
|
|
176
|
+
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
// Capture whatever the current nav color is (set by PageNavColor or default)
|
|
179
|
+
// We read it via a small trick: query the nav element's computed color
|
|
180
|
+
// But simpler: we just capture "" and let PageNavColor handle page-level.
|
|
181
|
+
// On unmount, reset to "" so PageNavColor can reclaim.
|
|
182
|
+
return () => {
|
|
183
|
+
setNavColor("");
|
|
184
|
+
};
|
|
185
|
+
}, [setNavColor]);
|
|
186
|
+
|
|
187
|
+
// ── Parallax scroll engine ──
|
|
188
|
+
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
// Determine if parallax should be disabled
|
|
191
|
+
const isTouch = isTouchDevice();
|
|
192
|
+
const reducedMotion = prefersReducedMotion();
|
|
193
|
+
disableParallax.current = isTouch || reducedMotion;
|
|
194
|
+
|
|
195
|
+
// Nav color updates happen regardless of parallax/animation state
|
|
196
|
+
const hasNavColors = slideNavColors.some((c) => c && isValidHex(c));
|
|
197
|
+
|
|
198
|
+
if (disableParallax.current && !hasNavColors) {
|
|
199
|
+
// No animation loop needed and no nav color to track
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let ticking = false;
|
|
204
|
+
|
|
205
|
+
function onScroll() {
|
|
206
|
+
if (ticking) return;
|
|
207
|
+
ticking = true;
|
|
208
|
+
rafId.current = requestAnimationFrame(() => {
|
|
209
|
+
ticking = false;
|
|
210
|
+
updateParallax();
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function updateParallax() {
|
|
215
|
+
const scrollY = window.scrollY;
|
|
216
|
+
const vh = window.innerHeight;
|
|
217
|
+
|
|
218
|
+
// ── Nav color interpolation (runs even on touch / reduced-motion) ──
|
|
219
|
+
if (hasNavColors) {
|
|
220
|
+
const groupEl = groupRef.current;
|
|
221
|
+
if (groupEl) {
|
|
222
|
+
const groupRect = groupEl.getBoundingClientRect();
|
|
223
|
+
const groupVisibleHeight = Math.min(groupRect.bottom, vh) - Math.max(groupRect.top, 0);
|
|
224
|
+
// Only apply nav color when parallax group is significantly in viewport
|
|
225
|
+
if (groupVisibleHeight / vh > 0.3) {
|
|
226
|
+
const interpolatedColor = computeNavColor(scrollY, vh);
|
|
227
|
+
if (interpolatedColor) {
|
|
228
|
+
setNavColor(interpolatedColor);
|
|
229
|
+
}
|
|
230
|
+
} else {
|
|
231
|
+
// Group is mostly off-screen — clear override so page color takes over
|
|
232
|
+
setNavColor("");
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Skip visual parallax effects on touch / reduced-motion
|
|
238
|
+
if (disableParallax.current) return;
|
|
239
|
+
|
|
240
|
+
slideRefsMap.current.forEach((refs) => {
|
|
241
|
+
const { slide, bg } = refs;
|
|
242
|
+
if (!slide || !bg) return;
|
|
243
|
+
|
|
244
|
+
// Only process slides within ±1 viewport of current scroll position
|
|
245
|
+
const slideTop = slide.offsetTop;
|
|
246
|
+
const slideBottom = slideTop + slide.offsetHeight;
|
|
247
|
+
const viewTop = scrollY - vh;
|
|
248
|
+
const viewBottom = scrollY + vh * 2;
|
|
249
|
+
|
|
250
|
+
if (slideBottom < viewTop || slideTop > viewBottom) return;
|
|
251
|
+
|
|
252
|
+
if (effect === "parallax" || effect === "reveal") {
|
|
253
|
+
// Parallax: translate background based on scroll offset
|
|
254
|
+
const offset = (scrollY - slideTop) * parallaxIntensity;
|
|
255
|
+
bg.style.transform = `translate3d(0, ${offset}px, 0)`;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (effect === "crossfade") {
|
|
259
|
+
// Crossfade: calculate opacity based on slide visibility
|
|
260
|
+
const slideCenter = slideTop + slide.offsetHeight / 2;
|
|
261
|
+
const viewCenter = scrollY + vh / 2;
|
|
262
|
+
const distance = Math.abs(viewCenter - slideCenter);
|
|
263
|
+
const maxDistance = vh;
|
|
264
|
+
const opacity = Math.max(0, Math.min(1, 1 - distance / maxDistance));
|
|
265
|
+
bg.style.opacity = String(opacity);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Reveal effect: clip-path on slides
|
|
270
|
+
if (effect === "reveal") {
|
|
271
|
+
applyRevealEffect(scrollY, vh);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function applyRevealEffect(scrollY: number, vh: number) {
|
|
276
|
+
const slides = Array.from(slideRefsMap.current.entries());
|
|
277
|
+
slides.forEach(([, refs], i) => {
|
|
278
|
+
const { slide } = refs;
|
|
279
|
+
if (!slide) return;
|
|
280
|
+
|
|
281
|
+
const slideTop = slide.offsetTop;
|
|
282
|
+
const progress = Math.max(0, Math.min(1, (scrollY - slideTop + vh) / vh));
|
|
283
|
+
|
|
284
|
+
if (i > 0) {
|
|
285
|
+
// For slides after the first, reveal from bottom via clip-path
|
|
286
|
+
// progress: 0 = fully hidden, 1 = fully revealed
|
|
287
|
+
const clipBottom = Math.max(0, (1 - progress) * 100);
|
|
288
|
+
slide.style.clipPath = `inset(0 0 ${clipBottom}% 0)`;
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
294
|
+
|
|
295
|
+
// Initial update
|
|
296
|
+
updateParallax();
|
|
297
|
+
|
|
298
|
+
return () => {
|
|
299
|
+
window.removeEventListener("scroll", onScroll);
|
|
300
|
+
if (rafId.current) cancelAnimationFrame(rafId.current);
|
|
301
|
+
};
|
|
302
|
+
}, [effect, parallaxIntensity, slideNavColors, computeNavColor, setNavColor]);
|
|
303
|
+
|
|
304
|
+
// ── Crossfade initial state: set opacity=0 for non-first slides ──
|
|
305
|
+
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
if (effect !== "crossfade") return;
|
|
308
|
+
|
|
309
|
+
// On mount, set initial opacity for crossfade effect
|
|
310
|
+
// NOTE: no CSS transition — opacity is updated per-frame by the scroll engine
|
|
311
|
+
const slides = Array.from(slideRefsMap.current.entries());
|
|
312
|
+
slides.forEach(([, refs], i) => {
|
|
313
|
+
if (refs.bg) {
|
|
314
|
+
refs.bg.style.opacity = i === 0 ? "1" : "0";
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
}, [effect]);
|
|
318
|
+
|
|
319
|
+
// ── JS-based snap behavior ──
|
|
320
|
+
|
|
321
|
+
useEffect(() => {
|
|
322
|
+
if (!snapEnabled) return;
|
|
323
|
+
if (prefersReducedMotion()) return;
|
|
324
|
+
|
|
325
|
+
function onPointerDown() {
|
|
326
|
+
isPointerDown.current = true;
|
|
327
|
+
// Clear any pending snap while user is actively scrolling
|
|
328
|
+
if (snapTimeoutId.current) {
|
|
329
|
+
clearTimeout(snapTimeoutId.current);
|
|
330
|
+
snapTimeoutId.current = null;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function onPointerUp() {
|
|
335
|
+
isPointerDown.current = false;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function onScroll() {
|
|
339
|
+
if (isSnapping.current || isPointerDown.current) return;
|
|
340
|
+
|
|
341
|
+
// Debounce: wait for scroll to idle before attempting snap
|
|
342
|
+
if (snapTimeoutId.current) {
|
|
343
|
+
clearTimeout(snapTimeoutId.current);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
snapTimeoutId.current = setTimeout(() => {
|
|
347
|
+
attemptSnap();
|
|
348
|
+
}, SNAP_IDLE_MS);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function attemptSnap() {
|
|
352
|
+
if (isPointerDown.current || isSnapping.current) return;
|
|
353
|
+
|
|
354
|
+
const groupEl = groupRef.current;
|
|
355
|
+
if (!groupEl) return;
|
|
356
|
+
|
|
357
|
+
const scrollY = window.scrollY;
|
|
358
|
+
const vh = window.innerHeight;
|
|
359
|
+
|
|
360
|
+
// Check if parallax group dominates the viewport
|
|
361
|
+
const groupRect = groupEl.getBoundingClientRect();
|
|
362
|
+
const groupVisibleHeight = Math.min(groupRect.bottom, vh) - Math.max(groupRect.top, 0);
|
|
363
|
+
if (groupVisibleHeight / vh < SNAP_MIN_VIEWPORT_RATIO) return;
|
|
364
|
+
|
|
365
|
+
// Find the closest slide boundary within snap zone
|
|
366
|
+
const snapZone = vh * SNAP_ZONE_VH;
|
|
367
|
+
let closestDist = Infinity;
|
|
368
|
+
let snapTarget: number | null = null;
|
|
369
|
+
|
|
370
|
+
slideRefsMap.current.forEach((refs) => {
|
|
371
|
+
const { slide } = refs;
|
|
372
|
+
if (!slide) return;
|
|
373
|
+
|
|
374
|
+
const slideTop = slide.offsetTop;
|
|
375
|
+
const dist = Math.abs(scrollY - slideTop);
|
|
376
|
+
|
|
377
|
+
if (dist < snapZone && dist < closestDist) {
|
|
378
|
+
closestDist = dist;
|
|
379
|
+
snapTarget = slideTop;
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
if (snapTarget !== null && closestDist > 2) {
|
|
384
|
+
// Don't snap if we're already at the target (within 2px)
|
|
385
|
+
isSnapping.current = true;
|
|
386
|
+
window.scrollTo({ top: snapTarget, behavior: "smooth" });
|
|
387
|
+
|
|
388
|
+
// Reset snapping flag after animation completes
|
|
389
|
+
if (snapResetTimeoutId.current) clearTimeout(snapResetTimeoutId.current);
|
|
390
|
+
snapResetTimeoutId.current = setTimeout(() => {
|
|
391
|
+
isSnapping.current = false;
|
|
392
|
+
}, 500);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Use scrollend if available, otherwise fall back to debounced scroll
|
|
397
|
+
const hasScrollEnd = "onscrollend" in window;
|
|
398
|
+
|
|
399
|
+
if (hasScrollEnd) {
|
|
400
|
+
window.addEventListener("scrollend", attemptSnap, { passive: true });
|
|
401
|
+
}
|
|
402
|
+
// Always listen to scroll for debounce fallback (scrollend may not fire on all platforms)
|
|
403
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
404
|
+
window.addEventListener("pointerdown", onPointerDown, { passive: true });
|
|
405
|
+
window.addEventListener("pointerup", onPointerUp, { passive: true });
|
|
406
|
+
|
|
407
|
+
return () => {
|
|
408
|
+
if (hasScrollEnd) {
|
|
409
|
+
window.removeEventListener("scrollend", attemptSnap);
|
|
410
|
+
}
|
|
411
|
+
window.removeEventListener("scroll", onScroll);
|
|
412
|
+
window.removeEventListener("pointerdown", onPointerDown);
|
|
413
|
+
window.removeEventListener("pointerup", onPointerUp);
|
|
414
|
+
if (snapTimeoutId.current) clearTimeout(snapTimeoutId.current);
|
|
415
|
+
if (snapResetTimeoutId.current) clearTimeout(snapResetTimeoutId.current);
|
|
416
|
+
};
|
|
417
|
+
}, [snapEnabled]);
|
|
418
|
+
|
|
419
|
+
// ── Render ──
|
|
420
|
+
|
|
421
|
+
const slides = group.slides || [];
|
|
422
|
+
|
|
423
|
+
if (slides.length === 0) {
|
|
424
|
+
return null; // Empty parallax group — render nothing on public site
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return (
|
|
428
|
+
<div
|
|
429
|
+
ref={groupRef}
|
|
430
|
+
className="parallax-group"
|
|
431
|
+
role="region"
|
|
432
|
+
aria-label="Parallax showcase"
|
|
433
|
+
>
|
|
434
|
+
{slides.map((slide, index) => (
|
|
435
|
+
<ParallaxSlideRenderer
|
|
436
|
+
key={slide._key}
|
|
437
|
+
slide={slide}
|
|
438
|
+
effect={effect}
|
|
439
|
+
pageEnterAnimation={pageEnterAnimation}
|
|
440
|
+
index={index}
|
|
441
|
+
bgRef={getBgRef(slide._key)}
|
|
442
|
+
slideRef={getSlideRef(slide._key)}
|
|
443
|
+
disableParallax={disableParallax.current || effect === "crossfade"}
|
|
444
|
+
/>
|
|
445
|
+
))}
|
|
446
|
+
</div>
|
|
447
|
+
);
|
|
448
|
+
}
|