@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.
Files changed (319) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -0
  3. package/admin/assets.ts +4 -0
  4. package/admin/database.ts +4 -0
  5. package/admin/index.ts +6 -0
  6. package/admin/login.ts +4 -0
  7. package/admin/navigation.ts +4 -0
  8. package/admin/pages-editor.ts +4 -0
  9. package/admin/pages.ts +4 -0
  10. package/admin/projects-editor.ts +4 -0
  11. package/admin/projects.ts +4 -0
  12. package/admin/settings.ts +4 -0
  13. package/admin/setup.ts +4 -0
  14. package/admin/storage.ts +4 -0
  15. package/admin/styles.ts +4 -0
  16. package/app/(site)/[slug]/loading.tsx +20 -0
  17. package/app/(site)/[slug]/page.tsx +83 -0
  18. package/app/(site)/error.tsx +32 -0
  19. package/app/(site)/layout.tsx +53 -0
  20. package/app/(site)/loading.tsx +20 -0
  21. package/app/(site)/not-found.tsx +41 -0
  22. package/app/(site)/page.tsx +43 -0
  23. package/app/(site)/preview/page.tsx +99 -0
  24. package/app/(site)/work/[slug]/loading.tsx +23 -0
  25. package/app/(site)/work/[slug]/page.tsx +84 -0
  26. package/app/admin/assets/page.tsx +573 -0
  27. package/app/admin/database/page.tsx +302 -0
  28. package/app/admin/error.tsx +53 -0
  29. package/app/admin/layout.tsx +273 -0
  30. package/app/admin/login/page.tsx +88 -0
  31. package/app/admin/navigation/page.tsx +157 -0
  32. package/app/admin/page.tsx +17 -0
  33. package/app/admin/pages/[slug]/page.tsx +849 -0
  34. package/app/admin/pages/page.tsx +588 -0
  35. package/app/admin/projects/[slug]/page.tsx +3 -0
  36. package/app/admin/projects/page.tsx +669 -0
  37. package/app/admin/settings/page.tsx +132 -0
  38. package/app/admin/setup/page.tsx +64 -0
  39. package/app/admin/storage/page.tsx +518 -0
  40. package/app/admin/styles/page.tsx +243 -0
  41. package/app/api/admin/assets/file/route.ts +81 -0
  42. package/app/api/admin/assets/health/route.ts +170 -0
  43. package/app/api/admin/assets/register/route.ts +163 -0
  44. package/app/api/admin/assets/registry/route.ts +98 -0
  45. package/app/api/admin/assets/relink/confirm/route.ts +242 -0
  46. package/app/api/admin/assets/relink/route.ts +202 -0
  47. package/app/api/admin/assets/scan/route.ts +271 -0
  48. package/app/api/admin/auth/route.ts +160 -0
  49. package/app/api/admin/custom-sections/[slug]/route.ts +159 -0
  50. package/app/api/admin/custom-sections/route.ts +127 -0
  51. package/app/api/admin/database/route.ts +53 -0
  52. package/app/api/admin/pages/[slug]/duplicate/route.ts +91 -0
  53. package/app/api/admin/pages/[slug]/route.ts +617 -0
  54. package/app/api/admin/pages/[slug]/set-home/route.ts +76 -0
  55. package/app/api/admin/pages/route.ts +129 -0
  56. package/app/api/admin/preview/route.ts +53 -0
  57. package/app/api/admin/r2/connect/route.ts +181 -0
  58. package/app/api/admin/r2/delete/route.ts +198 -0
  59. package/app/api/admin/r2/disconnect/route.ts +42 -0
  60. package/app/api/admin/r2/rename/route.ts +265 -0
  61. package/app/api/admin/r2/status/route.ts +106 -0
  62. package/app/api/admin/r2/upload-url/route.ts +148 -0
  63. package/app/api/admin/revalidate/route.ts +55 -0
  64. package/app/api/admin/settings/route.ts +279 -0
  65. package/app/api/admin/setup/complete/route.ts +51 -0
  66. package/app/api/admin/setup/route.ts +118 -0
  67. package/app/api/admin/storage/switch/route.ts +117 -0
  68. package/app/api/admin/styles/fonts/route.ts +97 -0
  69. package/app/api/admin/styles/route.ts +304 -0
  70. package/app/api/assets/[...path]/route.ts +98 -0
  71. package/app/api/custom-sections/[id]/route.ts +43 -0
  72. package/app/api/draft-mode/disable/route.ts +10 -0
  73. package/app/api/draft-mode/enable/route.ts +26 -0
  74. package/app/api/projects/route.ts +42 -0
  75. package/app/api/styles/route.ts +88 -0
  76. package/app/favicon.ico +0 -0
  77. package/app/globals.css +7 -0
  78. package/app/layout.tsx +53 -0
  79. package/app/robots.ts +17 -0
  80. package/app/sitemap.ts +48 -0
  81. package/app/studio/[[...index]]/page.tsx +8 -0
  82. package/components/admin/MetadataEditor.tsx +173 -0
  83. package/components/admin/PublishToggle.tsx +130 -0
  84. package/components/admin/icons.tsx +40 -0
  85. package/components/admin/nav-builder/NavBuilder.tsx +182 -0
  86. package/components/admin/nav-builder/NavBuilderGrid.tsx +326 -0
  87. package/components/admin/nav-builder/NavGeneralSettings.tsx +275 -0
  88. package/components/admin/nav-builder/NavGridCell.tsx +48 -0
  89. package/components/admin/nav-builder/NavGridItem.tsx +189 -0
  90. package/components/admin/nav-builder/NavItemSettings.tsx +288 -0
  91. package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -0
  92. package/components/admin/nav-builder/NavLivePreview.tsx +125 -0
  93. package/components/admin/nav-builder/NavSettingsFields.tsx +248 -0
  94. package/components/admin/nav-builder/NavSettingsPanel.tsx +127 -0
  95. package/components/admin/nav-builder/index.ts +10 -0
  96. package/components/admin/nav-builder/nav-builder-utils.ts +238 -0
  97. package/components/admin/setup-wizard/BrandingStep.tsx +218 -0
  98. package/components/admin/setup-wizard/DatabaseStep.tsx +331 -0
  99. package/components/admin/setup-wizard/DoneStep.tsx +187 -0
  100. package/components/admin/setup-wizard/SetupWizard.tsx +166 -0
  101. package/components/admin/setup-wizard/StorageStep.tsx +308 -0
  102. package/components/admin/setup-wizard/WelcomeStep.tsx +96 -0
  103. package/components/admin/setup-wizard/index.ts +9 -0
  104. package/components/admin/styles/ColorsEditor.tsx +214 -0
  105. package/components/admin/styles/FontsEditor.tsx +258 -0
  106. package/components/admin/styles/GridLayoutEditor.tsx +292 -0
  107. package/components/admin/styles/LinksButtonsEditor.tsx +120 -0
  108. package/components/admin/styles/TypographyEditor.tsx +266 -0
  109. package/components/admin/styles/index.ts +9 -0
  110. package/components/admin/styles/shared.tsx +68 -0
  111. package/components/blocks/BlockRenderer.tsx +404 -0
  112. package/components/blocks/ButtonBlockRenderer.tsx +52 -0
  113. package/components/blocks/CoverBlockRenderer.tsx +239 -0
  114. package/components/blocks/CustomSectionInstanceRenderer.tsx +82 -0
  115. package/components/blocks/EnterAnimationWrapper.tsx +140 -0
  116. package/components/blocks/HoverAnimationWrapper.tsx +308 -0
  117. package/components/blocks/ImageBlockRenderer.tsx +61 -0
  118. package/components/blocks/ImageGridBlockRenderer.tsx +545 -0
  119. package/components/blocks/PageBackground.tsx +28 -0
  120. package/components/blocks/PageNavAnimation.tsx +35 -0
  121. package/components/blocks/PageNavColor.tsx +24 -0
  122. package/components/blocks/PageRenderer.tsx +142 -0
  123. package/components/blocks/ParallaxGroupRenderer.tsx +448 -0
  124. package/components/blocks/ParallaxSlideRenderer.tsx +175 -0
  125. package/components/blocks/ProjectGridBlockRenderer.tsx +556 -0
  126. package/components/blocks/SectionRenderer.tsx +170 -0
  127. package/components/blocks/SectionV2Renderer.tsx +330 -0
  128. package/components/blocks/ShaderCanvas.tsx +392 -0
  129. package/components/blocks/SpacerBlockRenderer.tsx +17 -0
  130. package/components/blocks/TextBlockRenderer.tsx +87 -0
  131. package/components/blocks/TypewriterRichText.tsx +464 -0
  132. package/components/blocks/TypewriterWrapper.tsx +149 -0
  133. package/components/blocks/VideoBlockRenderer.tsx +304 -0
  134. package/components/blocks/index.ts +2 -0
  135. package/components/builder/AssetBrowser.tsx +2 -0
  136. package/components/builder/BlockLivePreview.tsx +101 -0
  137. package/components/builder/BlockTypePicker.tsx +178 -0
  138. package/components/builder/BuilderCanvas.tsx +354 -0
  139. package/components/builder/CanvasMinimap.tsx +200 -0
  140. package/components/builder/CanvasToolbar.tsx +202 -0
  141. package/components/builder/ColorPicker.tsx +243 -0
  142. package/components/builder/ColorSwatchPicker.tsx +274 -0
  143. package/components/builder/ColumnDragContext.tsx +51 -0
  144. package/components/builder/ColumnDragOverlay.tsx +110 -0
  145. package/components/builder/CustomSectionInstanceCard.tsx +97 -0
  146. package/components/builder/DeviceFrame.tsx +123 -0
  147. package/components/builder/DndWrapper.tsx +337 -0
  148. package/components/builder/InsertionLines.tsx +186 -0
  149. package/components/builder/ParallaxGroupCanvas.tsx +228 -0
  150. package/components/builder/ParallaxSlideHeader.tsx +113 -0
  151. package/components/builder/ReadOnlyFrame.tsx +417 -0
  152. package/components/builder/SectionEditorBar.tsx +288 -0
  153. package/components/builder/SectionTypePicker.tsx +422 -0
  154. package/components/builder/SectionV2Canvas.tsx +297 -0
  155. package/components/builder/SectionV2Column.tsx +488 -0
  156. package/components/builder/SettingsPanel.tsx +911 -0
  157. package/components/builder/SortableBlock.tsx +230 -0
  158. package/components/builder/SortableRow.tsx +362 -0
  159. package/components/builder/VirtualAssetGrid.tsx +397 -0
  160. package/components/builder/asset-browser/AssetBrowser.tsx +178 -0
  161. package/components/builder/asset-browser/FileLightbox.tsx +116 -0
  162. package/components/builder/asset-browser/FolderTreeItem.tsx +55 -0
  163. package/components/builder/asset-browser/R2BrowserContent.tsx +436 -0
  164. package/components/builder/asset-browser/R2ContextMenu.tsx +98 -0
  165. package/components/builder/asset-browser/VideoThumbnail.tsx +63 -0
  166. package/components/builder/asset-browser/helpers.ts +88 -0
  167. package/components/builder/asset-browser/index.ts +1 -0
  168. package/components/builder/asset-browser/types.ts +49 -0
  169. package/components/builder/asset-browser/useAssetBrowser.ts +344 -0
  170. package/components/builder/asset-browser/useR2DragDrop.ts +116 -0
  171. package/components/builder/asset-browser/useR2Operations.ts +189 -0
  172. package/components/builder/blockStyles.tsx +295 -0
  173. package/components/builder/editors/ButtonBlockEditor.tsx +184 -0
  174. package/components/builder/editors/CoverBlockEditor.tsx +488 -0
  175. package/components/builder/editors/EnterAnimationPicker.tsx +297 -0
  176. package/components/builder/editors/HoverEffectPicker.tsx +209 -0
  177. package/components/builder/editors/ImageBlockEditor.tsx +206 -0
  178. package/components/builder/editors/ImageGridBlockEditor.tsx +386 -0
  179. package/components/builder/editors/ProjectGridEditor.tsx +648 -0
  180. package/components/builder/editors/SpacerBlockEditor.tsx +167 -0
  181. package/components/builder/editors/StaggerSettings.tsx +108 -0
  182. package/components/builder/editors/TextAlignmentIcons.tsx +39 -0
  183. package/components/builder/editors/TextBlockEditor.tsx +462 -0
  184. package/components/builder/editors/TextStylePicker.tsx +183 -0
  185. package/components/builder/editors/VideoBlockEditor.tsx +278 -0
  186. package/components/builder/editors/index.ts +10 -0
  187. package/components/builder/editors/shared.tsx +345 -0
  188. package/components/builder/hooks/useColumnDrag.ts +472 -0
  189. package/components/builder/hooks/useColumnResize.ts +221 -0
  190. package/components/builder/index.ts +12 -0
  191. package/components/builder/live-preview/LiveButtonPreview.tsx +38 -0
  192. package/components/builder/live-preview/LiveCoverPreview.tsx +146 -0
  193. package/components/builder/live-preview/LiveImageGridPreview.tsx +123 -0
  194. package/components/builder/live-preview/LiveImagePreview.tsx +107 -0
  195. package/components/builder/live-preview/LiveProjectGridPreview.tsx +1010 -0
  196. package/components/builder/live-preview/LiveSpacerPreview.tsx +9 -0
  197. package/components/builder/live-preview/LiveTextEditor.tsx +198 -0
  198. package/components/builder/live-preview/LiveVideoPreview.tsx +98 -0
  199. package/components/builder/live-preview/index.ts +10 -0
  200. package/components/builder/live-preview/shared.tsx +153 -0
  201. package/components/builder/settings-panel/BlockLayoutTab.tsx +532 -0
  202. package/components/builder/settings-panel/BlockSettings.tsx +94 -0
  203. package/components/builder/settings-panel/ColumnV2Settings.tsx +160 -0
  204. package/components/builder/settings-panel/LayoutTab.tsx +310 -0
  205. package/components/builder/settings-panel/PageSettings.tsx +200 -0
  206. package/components/builder/settings-panel/ParallaxGroupSettings.tsx +118 -0
  207. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +178 -0
  208. package/components/builder/settings-panel/SectionV2AnimationTab.tsx +103 -0
  209. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +312 -0
  210. package/components/builder/settings-panel/SectionV2Settings.tsx +323 -0
  211. package/components/builder/settings-panel/TRBLInputs.tsx +51 -0
  212. package/components/builder/settings-panel/index.ts +19 -0
  213. package/components/builder/settings-panel/responsive-helpers.ts +524 -0
  214. package/components/ui/CustomCursor.tsx +118 -0
  215. package/components/ui/NavContentLightbox.tsx +152 -0
  216. package/components/ui/Navbar.tsx +582 -0
  217. package/components/ui/PortfolioTracker.tsx +87 -0
  218. package/components/ui/ScrollToTop.tsx +47 -0
  219. package/lib/animation/enter-presets.ts +147 -0
  220. package/lib/animation/enter-resolve.ts +90 -0
  221. package/lib/animation/enter-types.ts +128 -0
  222. package/lib/animation/hover-effect-presets.ts +210 -0
  223. package/lib/animation/hover-effect-types.ts +126 -0
  224. package/lib/asset-retry.ts +111 -0
  225. package/lib/assets.ts +92 -0
  226. package/lib/audit.ts +35 -0
  227. package/lib/auth-token.ts +94 -0
  228. package/lib/auth.ts +13 -0
  229. package/lib/builder/cascade-helpers.ts +51 -0
  230. package/lib/builder/cascade.ts +533 -0
  231. package/lib/builder/constants.ts +103 -0
  232. package/lib/builder/defaults.ts +182 -0
  233. package/lib/builder/history.ts +48 -0
  234. package/lib/builder/index.ts +21 -0
  235. package/lib/builder/layout-styles.ts +344 -0
  236. package/lib/builder/masonry.ts +166 -0
  237. package/lib/builder/responsive.ts +156 -0
  238. package/lib/builder/serializer.ts +845 -0
  239. package/lib/builder/store-blocks.ts +193 -0
  240. package/lib/builder/store-canvas.ts +319 -0
  241. package/lib/builder/store-helpers.ts +490 -0
  242. package/lib/builder/store-sections.ts +709 -0
  243. package/lib/builder/store.ts +333 -0
  244. package/lib/builder/templates.ts +297 -0
  245. package/lib/builder/types.ts +374 -0
  246. package/lib/builder/utils.ts +37 -0
  247. package/lib/color-utils.ts +116 -0
  248. package/lib/config/index.ts +57 -0
  249. package/lib/config/types.ts +122 -0
  250. package/lib/contexts/AssetContext.tsx +79 -0
  251. package/lib/contexts/NavAnimationContext.tsx +44 -0
  252. package/lib/contexts/NavColorContext.tsx +38 -0
  253. package/lib/contexts/PageExitContext.tsx +194 -0
  254. package/lib/contexts/ThumbStatusContext.tsx +83 -0
  255. package/lib/csrf-client.ts +34 -0
  256. package/lib/csrf.ts +68 -0
  257. package/lib/format-utils.ts +24 -0
  258. package/lib/hooks/useViewport.ts +42 -0
  259. package/lib/logger.ts +81 -0
  260. package/lib/revalidate.ts +23 -0
  261. package/lib/sanitize.ts +91 -0
  262. package/lib/sanity/client.ts +8 -0
  263. package/lib/sanity/queries.ts +486 -0
  264. package/lib/sanity/types.ts +869 -0
  265. package/lib/sanity/writeClient.ts +24 -0
  266. package/lib/security.ts +402 -0
  267. package/lib/setup/detect.ts +156 -0
  268. package/lib/shader/glsl/index.ts +27 -0
  269. package/lib/shader/glsl/pixelate.ts +51 -0
  270. package/lib/shader/glsl/rgb-shift.ts +45 -0
  271. package/lib/shader/glsl/ripple.ts +46 -0
  272. package/lib/shader/glsl/vertex.ts +14 -0
  273. package/lib/storage/index.ts +211 -0
  274. package/lib/storage/r2-adapter.ts +286 -0
  275. package/lib/storage/types.ts +125 -0
  276. package/lib/styles/provider.tsx +267 -0
  277. package/lib/thumbnails/generate.ts +151 -0
  278. package/lib/utils.ts +6 -0
  279. package/package.json +212 -0
  280. package/sanity/compose.ts +65 -0
  281. package/sanity/sanity.config.ts +126 -0
  282. package/sanity/schemas/assetRegistry.ts +301 -0
  283. package/sanity/schemas/blocks/blockLayout.ts +90 -0
  284. package/sanity/schemas/blocks/buttonBlock.ts +82 -0
  285. package/sanity/schemas/blocks/coverBlock.ts +229 -0
  286. package/sanity/schemas/blocks/imageBlock.ts +58 -0
  287. package/sanity/schemas/blocks/imageGridBlock.ts +112 -0
  288. package/sanity/schemas/blocks/index.ts +9 -0
  289. package/sanity/schemas/blocks/projectGridBlock.ts +251 -0
  290. package/sanity/schemas/blocks/spacerBlock.ts +41 -0
  291. package/sanity/schemas/blocks/textBlock.ts +139 -0
  292. package/sanity/schemas/blocks/videoBlock.ts +80 -0
  293. package/sanity/schemas/customSection.ts +69 -0
  294. package/sanity/schemas/customSectionInstance.ts +163 -0
  295. package/sanity/schemas/index.ts +111 -0
  296. package/sanity/schemas/objects/enterAnimationConfig.ts +72 -0
  297. package/sanity/schemas/objects/hoverEffectConfig.ts +90 -0
  298. package/sanity/schemas/objects/parallaxGroup.ts +66 -0
  299. package/sanity/schemas/objects/parallaxSlide.ts +217 -0
  300. package/sanity/schemas/objects/typewriterConfig.ts +38 -0
  301. package/sanity/schemas/page.ts +162 -0
  302. package/sanity/schemas/pageSection.ts +157 -0
  303. package/sanity/schemas/pageSectionV2.ts +269 -0
  304. package/sanity/schemas/siteSettings.ts +256 -0
  305. package/sanity/schemas/siteStyles.ts +212 -0
  306. package/site/error.ts +4 -0
  307. package/site/index.ts +8 -0
  308. package/site/not-found.ts +4 -0
  309. package/site/page.ts +4 -0
  310. package/site/preview.ts +4 -0
  311. package/site/robots.ts +4 -0
  312. package/site/sitemap.ts +4 -0
  313. package/site/work.ts +4 -0
  314. package/studio/index.ts +4 -0
  315. package/styles/admin.css +85 -0
  316. package/styles/animations.css +237 -0
  317. package/styles/base.css +148 -0
  318. package/styles/globals.css +10 -0
  319. 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
+ }