@morphika/andami 0.1.2
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 +50 -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 +212 -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,545 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import type { ImageGridBlock } from "../../lib/sanity/types";
|
|
6
|
+
import { useAssetUrl, useThumbUrl } from "../../lib/contexts/AssetContext";
|
|
7
|
+
import { MAX_RETRIES, RETRY_ATTR, cacheBustUrl } from "../../lib/asset-retry";
|
|
8
|
+
import { BREAKPOINTS } from "../../lib/builder/constants";
|
|
9
|
+
|
|
10
|
+
/** Attribute to store the full-resolution fallback URL */
|
|
11
|
+
const FALLBACK_ATTR = "data-fallback-src";
|
|
12
|
+
/** Attribute to track whether we've already fallen back to full-res */
|
|
13
|
+
const FALLEN_BACK_ATTR = "data-fallen-back";
|
|
14
|
+
|
|
15
|
+
function handleImgError(e: React.SyntheticEvent<HTMLImageElement>) {
|
|
16
|
+
const img = e.currentTarget;
|
|
17
|
+
|
|
18
|
+
// If we haven't tried the full-res fallback yet, try it first
|
|
19
|
+
const fallbackSrc = img.getAttribute(FALLBACK_ATTR);
|
|
20
|
+
const fallenBack = img.getAttribute(FALLEN_BACK_ATTR);
|
|
21
|
+
|
|
22
|
+
if (fallbackSrc && !fallenBack && img.src !== fallbackSrc) {
|
|
23
|
+
if (process.env.NODE_ENV === "development") {
|
|
24
|
+
console.warn(`[thumbs] fallback: ${img.src} → ${fallbackSrc}`);
|
|
25
|
+
}
|
|
26
|
+
img.setAttribute(FALLEN_BACK_ATTR, "1");
|
|
27
|
+
img.src = fallbackSrc;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Standard retry logic with cache busting
|
|
32
|
+
const retries = parseInt(img.getAttribute(RETRY_ATTR) || "0", 10);
|
|
33
|
+
if (retries < MAX_RETRIES) {
|
|
34
|
+
const next = retries + 1;
|
|
35
|
+
img.setAttribute(RETRY_ATTR, String(next));
|
|
36
|
+
setTimeout(() => {
|
|
37
|
+
img.src = cacheBustUrl(img.src, next);
|
|
38
|
+
}, 1000 * next);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ============================================
|
|
43
|
+
// Random Grid Layout Generator (Semplice-style)
|
|
44
|
+
// ============================================
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Pre-defined row patterns for each mode.
|
|
48
|
+
* Each pattern is an array of column spans that sums to 12.
|
|
49
|
+
* The algorithm picks a random pattern per row, ensuring visual variety.
|
|
50
|
+
*/
|
|
51
|
+
const ROW_PATTERNS: Record<string, number[][]> = {
|
|
52
|
+
"small2-big4": [
|
|
53
|
+
[4, 4, 4], // 3 big
|
|
54
|
+
[2, 2, 4, 4], // 2 small + 2 big
|
|
55
|
+
[4, 2, 2, 4], // big + 2 small + big
|
|
56
|
+
[4, 4, 2, 2], // 2 big + 2 small
|
|
57
|
+
[2, 4, 4, 2], // small + 2 big + small
|
|
58
|
+
[2, 2, 2, 2, 4], // 4 small + 1 big
|
|
59
|
+
[4, 2, 2, 2, 2], // 1 big + 4 small
|
|
60
|
+
[2, 2, 2, 2, 2, 2],// 6 small
|
|
61
|
+
],
|
|
62
|
+
"small3-big6": [
|
|
63
|
+
[6, 3, 3], // 1 big + 2 small
|
|
64
|
+
[3, 6, 3], // small + big + small
|
|
65
|
+
[3, 3, 6], // 2 small + 1 big
|
|
66
|
+
[6, 6], // 2 big
|
|
67
|
+
[3, 3, 3, 3], // 4 small
|
|
68
|
+
],
|
|
69
|
+
"small4-big8": [
|
|
70
|
+
[8, 4], // 1 big + 1 small
|
|
71
|
+
[4, 8], // 1 small + 1 big
|
|
72
|
+
[4, 4, 4], // 3 small
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Seeded PRNG — produces repeatable sequences from a given seed.
|
|
78
|
+
*/
|
|
79
|
+
function createRng(seed: number) {
|
|
80
|
+
let s = seed;
|
|
81
|
+
return () => {
|
|
82
|
+
s = (s * 16807 + 12345) % 2147483647;
|
|
83
|
+
return (s & 0x7fffffff) / 0x7fffffff;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Generate column spans for each image in a 12-column grid.
|
|
89
|
+
* Uses pre-defined row patterns and picks one randomly per row.
|
|
90
|
+
* The last row is stretched proportionally to fill the full 12 columns (no gaps).
|
|
91
|
+
*/
|
|
92
|
+
function generateRandomGridSpans(
|
|
93
|
+
count: number,
|
|
94
|
+
mode: "small2-big4" | "small3-big6" | "small4-big8",
|
|
95
|
+
seed: number = 1
|
|
96
|
+
): number[] {
|
|
97
|
+
const patterns = ROW_PATTERNS[mode];
|
|
98
|
+
if (!patterns) return new Array(count).fill(Math.floor(12 / Math.max(count, 1)));
|
|
99
|
+
|
|
100
|
+
const rand = createRng(seed);
|
|
101
|
+
const spans: number[] = [];
|
|
102
|
+
let placed = 0;
|
|
103
|
+
|
|
104
|
+
while (placed < count) {
|
|
105
|
+
const imagesLeft = count - placed;
|
|
106
|
+
|
|
107
|
+
// Filter patterns that don't need more images than we have left
|
|
108
|
+
const valid = patterns.filter((p) => p.length <= imagesLeft);
|
|
109
|
+
|
|
110
|
+
// If no valid full pattern fits, handle the last partial row
|
|
111
|
+
if (valid.length === 0) {
|
|
112
|
+
// Stretch remaining images to fill 12 columns proportionally
|
|
113
|
+
const perImage = Math.floor(12 / imagesLeft);
|
|
114
|
+
let leftover = 12 - perImage * imagesLeft;
|
|
115
|
+
for (let i = 0; i < imagesLeft; i++) {
|
|
116
|
+
const extra = leftover > 0 ? 1 : 0;
|
|
117
|
+
spans.push(perImage + extra);
|
|
118
|
+
if (leftover > 0) leftover--;
|
|
119
|
+
}
|
|
120
|
+
placed += imagesLeft;
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Pick a random valid pattern
|
|
125
|
+
const pattern = valid[Math.floor(rand() * valid.length)];
|
|
126
|
+
for (const span of pattern) {
|
|
127
|
+
spans.push(span);
|
|
128
|
+
}
|
|
129
|
+
placed += pattern.length;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return spans;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ============================================
|
|
136
|
+
// Lightbox Component
|
|
137
|
+
// ============================================
|
|
138
|
+
|
|
139
|
+
/** Minimum horizontal distance (px) to count as a swipe */
|
|
140
|
+
const SWIPE_THRESHOLD = 50;
|
|
141
|
+
/** Maximum vertical distance (px) — beyond this it's a scroll, not a swipe */
|
|
142
|
+
const SWIPE_MAX_VERTICAL = 80;
|
|
143
|
+
|
|
144
|
+
/** Duration (ms) for the slide transition between images */
|
|
145
|
+
const SLIDE_DURATION = 250;
|
|
146
|
+
|
|
147
|
+
/** CSS keyframes injected once for lightbox slide animations */
|
|
148
|
+
const SLIDE_KEYFRAMES = `
|
|
149
|
+
@keyframes lb-slide-from-right {
|
|
150
|
+
from { opacity: 0; transform: translateX(60px); }
|
|
151
|
+
to { opacity: 1; transform: translateX(0); }
|
|
152
|
+
}
|
|
153
|
+
@keyframes lb-slide-from-left {
|
|
154
|
+
from { opacity: 0; transform: translateX(-60px); }
|
|
155
|
+
to { opacity: 1; transform: translateX(0); }
|
|
156
|
+
}
|
|
157
|
+
@keyframes lb-scale-in {
|
|
158
|
+
from { opacity: 0; transform: scale(0.92); }
|
|
159
|
+
to { opacity: 1; transform: scale(1); }
|
|
160
|
+
}
|
|
161
|
+
`;
|
|
162
|
+
|
|
163
|
+
function GridLightbox({
|
|
164
|
+
images,
|
|
165
|
+
currentIndex,
|
|
166
|
+
resolveAsset,
|
|
167
|
+
onClose,
|
|
168
|
+
onPrev,
|
|
169
|
+
onNext,
|
|
170
|
+
}: {
|
|
171
|
+
images: Array<{ asset_path: string; alt?: string }>;
|
|
172
|
+
currentIndex: number;
|
|
173
|
+
resolveAsset: (path: string) => string;
|
|
174
|
+
onClose: () => void;
|
|
175
|
+
onPrev: () => void;
|
|
176
|
+
onNext: () => void;
|
|
177
|
+
}) {
|
|
178
|
+
const img = images[currentIndex];
|
|
179
|
+
const [visible, setVisible] = useState(false);
|
|
180
|
+
const backdropRef = useRef<HTMLDivElement>(null);
|
|
181
|
+
|
|
182
|
+
// Track navigation direction for slide animation
|
|
183
|
+
// null = initial open (scale-in), "left" = next, "right" = prev
|
|
184
|
+
const slideDirRef = useRef<"left" | "right" | null>(null);
|
|
185
|
+
const prevIndexRef = useRef(currentIndex);
|
|
186
|
+
|
|
187
|
+
// Detect direction when currentIndex changes
|
|
188
|
+
if (currentIndex !== prevIndexRef.current) {
|
|
189
|
+
// Determine direction: going forward = slide from right, going back = slide from left
|
|
190
|
+
const diff = currentIndex - prevIndexRef.current;
|
|
191
|
+
// Handle wrap-around: if jumping from last→first it's "next" (left), first→last is "prev" (right)
|
|
192
|
+
if (diff === 1 || diff === -(images.length - 1)) {
|
|
193
|
+
slideDirRef.current = "left"; // next → image enters from right
|
|
194
|
+
} else {
|
|
195
|
+
slideDirRef.current = "right"; // prev → image enters from left
|
|
196
|
+
}
|
|
197
|
+
prevIndexRef.current = currentIndex;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Pick the animation name based on direction
|
|
201
|
+
const slideAnimation = slideDirRef.current === "left"
|
|
202
|
+
? `lb-slide-from-right ${SLIDE_DURATION}ms ease both`
|
|
203
|
+
: slideDirRef.current === "right"
|
|
204
|
+
? `lb-slide-from-left ${SLIDE_DURATION}ms ease both`
|
|
205
|
+
: undefined; // initial open uses the scale-in via visible state
|
|
206
|
+
|
|
207
|
+
// ---- Image preloading ----
|
|
208
|
+
// When the current image changes, preload the next and previous full-res
|
|
209
|
+
// images in the background so they're instant when the user navigates.
|
|
210
|
+
useEffect(() => {
|
|
211
|
+
if (images.length <= 1) return;
|
|
212
|
+
const toPreload: number[] = [
|
|
213
|
+
(currentIndex + 1) % images.length,
|
|
214
|
+
(currentIndex - 1 + images.length) % images.length,
|
|
215
|
+
];
|
|
216
|
+
const preloaded: HTMLImageElement[] = [];
|
|
217
|
+
for (const idx of toPreload) {
|
|
218
|
+
const preImg = new Image();
|
|
219
|
+
preImg.src = resolveAsset(images[idx].asset_path);
|
|
220
|
+
preloaded.push(preImg);
|
|
221
|
+
}
|
|
222
|
+
// Cleanup: abort any in-flight loads if we navigate away quickly
|
|
223
|
+
return () => {
|
|
224
|
+
for (const p of preloaded) p.src = "";
|
|
225
|
+
};
|
|
226
|
+
}, [currentIndex, images, resolveAsset]);
|
|
227
|
+
|
|
228
|
+
// ---- Touch / swipe handling ----
|
|
229
|
+
const touchRef = useRef<{ startX: number; startY: number; startTime: number } | null>(null);
|
|
230
|
+
|
|
231
|
+
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
|
232
|
+
const touch = e.touches[0];
|
|
233
|
+
touchRef.current = { startX: touch.clientX, startY: touch.clientY, startTime: Date.now() };
|
|
234
|
+
}, []);
|
|
235
|
+
|
|
236
|
+
const onTouchEnd = useCallback(
|
|
237
|
+
(e: React.TouchEvent) => {
|
|
238
|
+
if (!touchRef.current) return;
|
|
239
|
+
const touch = e.changedTouches[0];
|
|
240
|
+
const dx = touch.clientX - touchRef.current.startX;
|
|
241
|
+
const dy = touch.clientY - touchRef.current.startY;
|
|
242
|
+
touchRef.current = null;
|
|
243
|
+
|
|
244
|
+
// Only register as a swipe if horizontal distance exceeds threshold
|
|
245
|
+
// and vertical distance is small (not a scroll gesture)
|
|
246
|
+
if (Math.abs(dx) >= SWIPE_THRESHOLD && Math.abs(dy) <= SWIPE_MAX_VERTICAL) {
|
|
247
|
+
if (dx < 0) onNext(); // swipe left → next
|
|
248
|
+
else onPrev(); // swipe right → prev
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
[onNext, onPrev]
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
// Focus trap: keep focus inside the lightbox
|
|
255
|
+
const dialogRef = useRef<HTMLDivElement>(null);
|
|
256
|
+
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
|
|
257
|
+
|
|
258
|
+
// Trigger enter animation on mount + manage focus
|
|
259
|
+
useEffect(() => {
|
|
260
|
+
// Store the element that had focus before opening
|
|
261
|
+
previouslyFocusedRef.current = document.activeElement as HTMLElement;
|
|
262
|
+
// requestAnimationFrame ensures the initial opacity:0 is painted first
|
|
263
|
+
const raf = requestAnimationFrame(() => setVisible(true));
|
|
264
|
+
// Focus the dialog container so keyboard events work immediately
|
|
265
|
+
dialogRef.current?.focus();
|
|
266
|
+
return () => cancelAnimationFrame(raf);
|
|
267
|
+
}, []);
|
|
268
|
+
|
|
269
|
+
// Restore focus on unmount
|
|
270
|
+
useEffect(() => {
|
|
271
|
+
return () => {
|
|
272
|
+
previouslyFocusedRef.current?.focus();
|
|
273
|
+
};
|
|
274
|
+
}, []);
|
|
275
|
+
|
|
276
|
+
// Focus trap: cycle focus within the lightbox
|
|
277
|
+
useEffect(() => {
|
|
278
|
+
const dialog = dialogRef.current;
|
|
279
|
+
if (!dialog) return;
|
|
280
|
+
const handleTab = (e: KeyboardEvent) => {
|
|
281
|
+
if (e.key !== "Tab") return;
|
|
282
|
+
const focusable = dialog.querySelectorAll<HTMLElement>(
|
|
283
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
284
|
+
);
|
|
285
|
+
if (focusable.length === 0) return;
|
|
286
|
+
const first = focusable[0];
|
|
287
|
+
const last = focusable[focusable.length - 1];
|
|
288
|
+
if (e.shiftKey && document.activeElement === first) {
|
|
289
|
+
e.preventDefault();
|
|
290
|
+
last.focus();
|
|
291
|
+
} else if (!e.shiftKey && document.activeElement === last) {
|
|
292
|
+
e.preventDefault();
|
|
293
|
+
first.focus();
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
window.addEventListener("keydown", handleTab);
|
|
297
|
+
return () => window.removeEventListener("keydown", handleTab);
|
|
298
|
+
}, []);
|
|
299
|
+
|
|
300
|
+
// Animated close: fade out, then call onClose after transition
|
|
301
|
+
const handleClose = useCallback(() => {
|
|
302
|
+
setVisible(false);
|
|
303
|
+
setTimeout(onClose, 300);
|
|
304
|
+
}, [onClose]);
|
|
305
|
+
|
|
306
|
+
const handleKeyDown = useCallback(
|
|
307
|
+
(e: KeyboardEvent) => {
|
|
308
|
+
if (e.key === "Escape") handleClose();
|
|
309
|
+
if (e.key === "ArrowLeft") onPrev();
|
|
310
|
+
if (e.key === "ArrowRight") onNext();
|
|
311
|
+
},
|
|
312
|
+
[handleClose, onPrev, onNext]
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
useEffect(() => {
|
|
316
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
317
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
318
|
+
}, [handleKeyDown]);
|
|
319
|
+
|
|
320
|
+
if (!img) return null;
|
|
321
|
+
|
|
322
|
+
// Portal to document.body so the lightbox escapes any parent stacking
|
|
323
|
+
// contexts created by ScrollAnimationWrapper transforms / animation-timeline.
|
|
324
|
+
return createPortal(
|
|
325
|
+
<>
|
|
326
|
+
<style dangerouslySetInnerHTML={{ __html: SLIDE_KEYFRAMES }} />
|
|
327
|
+
<div
|
|
328
|
+
ref={(el) => {
|
|
329
|
+
(backdropRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
|
330
|
+
(dialogRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
|
331
|
+
}}
|
|
332
|
+
role="dialog"
|
|
333
|
+
aria-modal="true"
|
|
334
|
+
aria-label={`Image viewer: ${img.alt || `image ${currentIndex + 1} of ${images.length}`}`}
|
|
335
|
+
tabIndex={-1}
|
|
336
|
+
className="fixed inset-0 z-[9999] flex items-center justify-center outline-none"
|
|
337
|
+
onClick={handleClose}
|
|
338
|
+
onTouchStart={onTouchStart}
|
|
339
|
+
onTouchEnd={onTouchEnd}
|
|
340
|
+
style={{
|
|
341
|
+
backgroundColor: visible ? "rgba(0,0,0,0.85)" : "rgba(0,0,0,0)",
|
|
342
|
+
backdropFilter: visible ? "blur(8px)" : "blur(0px)",
|
|
343
|
+
transition: "background-color 300ms ease, backdrop-filter 300ms ease",
|
|
344
|
+
touchAction: "pan-y",
|
|
345
|
+
}}
|
|
346
|
+
>
|
|
347
|
+
{/* Close */}
|
|
348
|
+
<button
|
|
349
|
+
onClick={handleClose}
|
|
350
|
+
aria-label="Close image viewer"
|
|
351
|
+
className="absolute top-5 right-5 w-10 h-10 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors z-10"
|
|
352
|
+
style={{
|
|
353
|
+
opacity: visible ? 1 : 0,
|
|
354
|
+
transition: "opacity 300ms ease",
|
|
355
|
+
}}
|
|
356
|
+
>
|
|
357
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" aria-hidden="true">
|
|
358
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
359
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
360
|
+
</svg>
|
|
361
|
+
</button>
|
|
362
|
+
|
|
363
|
+
{/* Nav arrows */}
|
|
364
|
+
{images.length > 1 && (
|
|
365
|
+
<>
|
|
366
|
+
<button
|
|
367
|
+
onClick={(e) => { e.stopPropagation(); onPrev(); }}
|
|
368
|
+
aria-label="Previous image"
|
|
369
|
+
className="absolute left-5 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors z-10"
|
|
370
|
+
style={{
|
|
371
|
+
opacity: visible ? 1 : 0,
|
|
372
|
+
transition: "opacity 300ms ease 100ms",
|
|
373
|
+
}}
|
|
374
|
+
>
|
|
375
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" aria-hidden="true">
|
|
376
|
+
<polyline points="15 18 9 12 15 6" />
|
|
377
|
+
</svg>
|
|
378
|
+
</button>
|
|
379
|
+
<button
|
|
380
|
+
onClick={(e) => { e.stopPropagation(); onNext(); }}
|
|
381
|
+
aria-label="Next image"
|
|
382
|
+
className="absolute right-5 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors z-10"
|
|
383
|
+
style={{
|
|
384
|
+
opacity: visible ? 1 : 0,
|
|
385
|
+
transition: "opacity 300ms ease 100ms",
|
|
386
|
+
}}
|
|
387
|
+
>
|
|
388
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2" aria-hidden="true">
|
|
389
|
+
<polyline points="9 18 15 12 9 6" />
|
|
390
|
+
</svg>
|
|
391
|
+
</button>
|
|
392
|
+
</>
|
|
393
|
+
)}
|
|
394
|
+
|
|
395
|
+
{/* Counter */}
|
|
396
|
+
<div
|
|
397
|
+
className="absolute bottom-5 left-1/2 -translate-x-1/2 bg-black/50 backdrop-blur-sm rounded-full px-4 py-1.5 z-10"
|
|
398
|
+
style={{
|
|
399
|
+
opacity: visible ? 1 : 0,
|
|
400
|
+
transition: "opacity 300ms ease 150ms",
|
|
401
|
+
}}
|
|
402
|
+
>
|
|
403
|
+
<span className="text-xs text-white/70">
|
|
404
|
+
{currentIndex + 1} / {images.length}
|
|
405
|
+
</span>
|
|
406
|
+
</div>
|
|
407
|
+
|
|
408
|
+
{/* Image — key forces remount on index change to replay slide animation */}
|
|
409
|
+
<div
|
|
410
|
+
key={currentIndex}
|
|
411
|
+
className="max-w-[90vw] max-h-[85vh] flex items-center justify-center"
|
|
412
|
+
onClick={(e) => e.stopPropagation()}
|
|
413
|
+
style={slideAnimation
|
|
414
|
+
? { animation: slideAnimation }
|
|
415
|
+
: {
|
|
416
|
+
opacity: visible ? 1 : 0,
|
|
417
|
+
transform: visible ? "scale(1)" : "scale(0.92)",
|
|
418
|
+
transition: "opacity 300ms ease, transform 300ms ease",
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
>
|
|
422
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
423
|
+
<img
|
|
424
|
+
src={resolveAsset(img.asset_path)}
|
|
425
|
+
alt={img.alt ?? ""}
|
|
426
|
+
className="max-w-full max-h-[85vh] object-contain"
|
|
427
|
+
style={{ borderRadius: "2px", pointerEvents: "none" }}
|
|
428
|
+
/>
|
|
429
|
+
</div>
|
|
430
|
+
</div>
|
|
431
|
+
</>,
|
|
432
|
+
document.body
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ============================================
|
|
437
|
+
// Main Renderer
|
|
438
|
+
// ============================================
|
|
439
|
+
|
|
440
|
+
export default function ImageGridBlockRenderer({
|
|
441
|
+
block,
|
|
442
|
+
}: {
|
|
443
|
+
block: ImageGridBlock;
|
|
444
|
+
}) {
|
|
445
|
+
const resolveAsset = useAssetUrl();
|
|
446
|
+
const resolveThumb = useThumbUrl();
|
|
447
|
+
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
|
448
|
+
// Unique ID for phone responsive override — must be before early return (hooks rule)
|
|
449
|
+
const gridId = useRef(`ig-${Math.random().toString(36).slice(2, 8)}`).current;
|
|
450
|
+
|
|
451
|
+
// Block is already resolved for the current viewport by BlockRenderer
|
|
452
|
+
if (!block.images?.length) return null;
|
|
453
|
+
|
|
454
|
+
const images = block.images;
|
|
455
|
+
const hGutter = block.h_gutter ?? 10;
|
|
456
|
+
const vGutter = block.v_gutter ?? 10;
|
|
457
|
+
const imagesPerRow = block.images_per_row ?? 2;
|
|
458
|
+
const randomGrid = block.random_grid ?? "disabled";
|
|
459
|
+
const lightboxEnabled = block.lightbox ?? false;
|
|
460
|
+
const fit = block.object_fit ?? "cover";
|
|
461
|
+
const borderRadius = block.border_radius ? `${String(block.border_radius).replace(/px$/i, "")}px` : undefined;
|
|
462
|
+
|
|
463
|
+
// Determine if using random grid or uniform grid
|
|
464
|
+
const useRandomGrid = randomGrid !== "disabled";
|
|
465
|
+
const randomSeed = block.random_seed ?? 1;
|
|
466
|
+
const spans = useRandomGrid
|
|
467
|
+
? generateRandomGridSpans(images.length, randomGrid as "small2-big4" | "small3-big6" | "small4-big8", randomSeed)
|
|
468
|
+
: null;
|
|
469
|
+
|
|
470
|
+
// Uniform grid: columns = 12 / imagesPerRow
|
|
471
|
+
const uniformColSpan = 12 / imagesPerRow;
|
|
472
|
+
|
|
473
|
+
// On phone (≤640px), simplify grid: max 2 columns (span 6) for readability
|
|
474
|
+
const phoneSpan = Math.max(6, uniformColSpan);
|
|
475
|
+
const phoneCss = `@media(max-width:${BREAKPOINTS.phone}px){.${gridId}>div{grid-column:span ${phoneSpan}!important}}`;
|
|
476
|
+
|
|
477
|
+
return (
|
|
478
|
+
<>
|
|
479
|
+
<style dangerouslySetInnerHTML={{ __html: phoneCss }} />
|
|
480
|
+
<div
|
|
481
|
+
className={gridId}
|
|
482
|
+
style={{
|
|
483
|
+
display: "grid",
|
|
484
|
+
gridTemplateColumns: "repeat(12, 1fr)",
|
|
485
|
+
columnGap: `${hGutter}px`,
|
|
486
|
+
rowGap: `${vGutter}px`,
|
|
487
|
+
}}
|
|
488
|
+
>
|
|
489
|
+
{images.map((img, i) => {
|
|
490
|
+
const colSpan = spans ? spans[i] : uniformColSpan;
|
|
491
|
+
|
|
492
|
+
return (
|
|
493
|
+
<div
|
|
494
|
+
key={i}
|
|
495
|
+
style={{
|
|
496
|
+
gridColumn: `span ${colSpan}`,
|
|
497
|
+
minWidth: 0,
|
|
498
|
+
overflow: "hidden",
|
|
499
|
+
borderRadius,
|
|
500
|
+
}}
|
|
501
|
+
>
|
|
502
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
503
|
+
<img
|
|
504
|
+
src={resolveThumb(img.asset_path)}
|
|
505
|
+
data-fallback-src={resolveAsset(img.asset_path)}
|
|
506
|
+
alt={img.alt ?? ""}
|
|
507
|
+
loading="lazy"
|
|
508
|
+
decoding="async"
|
|
509
|
+
onError={handleImgError}
|
|
510
|
+
onClick={lightboxEnabled ? () => setLightboxIndex(i) : undefined}
|
|
511
|
+
style={{
|
|
512
|
+
width: "100%",
|
|
513
|
+
height: "100%",
|
|
514
|
+
display: "block",
|
|
515
|
+
objectFit: fit,
|
|
516
|
+
cursor: lightboxEnabled ? "pointer" : "default",
|
|
517
|
+
}}
|
|
518
|
+
/>
|
|
519
|
+
</div>
|
|
520
|
+
);
|
|
521
|
+
})}
|
|
522
|
+
</div>
|
|
523
|
+
|
|
524
|
+
{/* Lightbox */}
|
|
525
|
+
{lightboxEnabled && lightboxIndex !== null && (
|
|
526
|
+
<GridLightbox
|
|
527
|
+
images={images}
|
|
528
|
+
currentIndex={lightboxIndex}
|
|
529
|
+
resolveAsset={resolveAsset}
|
|
530
|
+
onClose={() => setLightboxIndex(null)}
|
|
531
|
+
onPrev={() =>
|
|
532
|
+
setLightboxIndex((prev) =>
|
|
533
|
+
prev !== null ? (prev - 1 + images.length) % images.length : 0
|
|
534
|
+
)
|
|
535
|
+
}
|
|
536
|
+
onNext={() =>
|
|
537
|
+
setLightboxIndex((prev) =>
|
|
538
|
+
prev !== null ? (prev + 1) % images.length : 0
|
|
539
|
+
)
|
|
540
|
+
}
|
|
541
|
+
/>
|
|
542
|
+
)}
|
|
543
|
+
</>
|
|
544
|
+
);
|
|
545
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Client component that syncs the body background-color to the current page's
|
|
7
|
+
* background. This prevents black body flashes during page transitions —
|
|
8
|
+
* when animated elements are at opacity:0, the body color matches the page
|
|
9
|
+
* instead of showing the default dark (#020202).
|
|
10
|
+
*
|
|
11
|
+
* The body has `transition: background-color 300ms ease` in globals.css,
|
|
12
|
+
* so navigating between pages with different backgrounds fades smoothly.
|
|
13
|
+
*
|
|
14
|
+
* Renders nothing — side-effect only. Resets to default on unmount.
|
|
15
|
+
*/
|
|
16
|
+
export function PageBackground({ color }: { color?: string }) {
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (color && color !== "transparent") {
|
|
19
|
+
document.body.style.backgroundColor = color;
|
|
20
|
+
}
|
|
21
|
+
return () => {
|
|
22
|
+
// Reset to CSS default (--color-brand-dark) when leaving the page
|
|
23
|
+
document.body.style.backgroundColor = "";
|
|
24
|
+
};
|
|
25
|
+
}, [color]);
|
|
26
|
+
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PageNavAnimation — Side-effect component that sets per-page nav animation override.
|
|
5
|
+
*
|
|
6
|
+
* Same pattern as PageNavColor: renders nothing, just pushes page-level
|
|
7
|
+
* settings into context. Resets on unmount.
|
|
8
|
+
*
|
|
9
|
+
* Session 115: Created for per-page nav entrance animation overrides.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useEffect } from "react";
|
|
13
|
+
import { useNavAnimation } from "../../lib/contexts/NavAnimationContext";
|
|
14
|
+
import type { NavEntrancePreset } from "../../lib/sanity/types";
|
|
15
|
+
|
|
16
|
+
interface PageNavAnimationProps {
|
|
17
|
+
preset?: NavEntrancePreset;
|
|
18
|
+
duration?: number;
|
|
19
|
+
delay?: number;
|
|
20
|
+
disabled?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function PageNavAnimation({ preset, duration, delay, disabled }: PageNavAnimationProps) {
|
|
24
|
+
const { setOverride } = useNavAnimation();
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
// Only push an override if the page actually configured something
|
|
28
|
+
if (preset || duration || delay || disabled) {
|
|
29
|
+
setOverride({ preset, duration, delay, disabled });
|
|
30
|
+
}
|
|
31
|
+
return () => setOverride({});
|
|
32
|
+
}, [preset, duration, delay, disabled, setOverride]);
|
|
33
|
+
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import { useNavColor } from "../../lib/contexts/NavColorContext";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Client component that sets the per-page nav color via context.
|
|
8
|
+
* Renders nothing — just a side-effect component.
|
|
9
|
+
* Resets to empty on unmount so navigation between pages works correctly.
|
|
10
|
+
*/
|
|
11
|
+
export function PageNavColor({ color }: { color: string }) {
|
|
12
|
+
const { setNavColor } = useNavColor();
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (color) {
|
|
16
|
+
setNavColor(color);
|
|
17
|
+
}
|
|
18
|
+
return () => {
|
|
19
|
+
setNavColor("");
|
|
20
|
+
};
|
|
21
|
+
}, [color, setNavColor]);
|
|
22
|
+
|
|
23
|
+
return null;
|
|
24
|
+
}
|