@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,556 @@
1
+ "use client";
2
+
3
+ /**
4
+ * ProjectGridBlockRenderer — Public site renderer for Project Grid v2.
5
+ *
6
+ * Uses the shared masonry engine (lib/builder/masonry.ts) with absolute
7
+ * positioning for pixel-perfect layout at every viewport.
8
+ *
9
+ * Features:
10
+ * - JS masonry layout (shortest-column algorithm)
11
+ * - 1–6 columns, per-card aspect ratio overrides
12
+ * - Video modes: off / hover / autoloop
13
+ * - Hover effects: scale / 3d / none
14
+ * - Subtitle overlay with gradient
15
+ * - Responsive: ResizeObserver recalculates on container resize
16
+ */
17
+
18
+ import {
19
+ useState,
20
+ useEffect,
21
+ useRef,
22
+ useCallback,
23
+ useMemo,
24
+ memo,
25
+ } from "react";
26
+ import Link from "next/link";
27
+ import { useAssetUrl, useThumbUrl } from "../../lib/contexts/AssetContext";
28
+ import {
29
+ computeMasonry,
30
+ resolveItemRatio,
31
+ type MasonryItem,
32
+ type MasonryOutput,
33
+ } from "../../lib/builder/masonry";
34
+ import type { ProjectGridBlock } from "../../lib/sanity/types";
35
+ import type { ResolvedEnterAnimationConfig } from "../../lib/animation/enter-types";
36
+ import { useViewport } from "../../lib/hooks/useViewport";
37
+ import EnterAnimationWrapper from "./EnterAnimationWrapper";
38
+
39
+ // ─── Resolved project data ───
40
+
41
+ interface ResolvedProject {
42
+ slug: string;
43
+ title: string;
44
+ subtitle?: string;
45
+ thumbnail_path?: string;
46
+ cover_video?: string;
47
+ aspect_ratio_override?: string | null;
48
+ /** Per-card responsive overrides (tablet/phone) */
49
+ responsive?: { tablet?: { aspect_ratio_override?: string | null }; phone?: { aspect_ratio_override?: string | null } };
50
+ _key: string;
51
+ }
52
+
53
+ // ─── Main Component ───
54
+
55
+ export default function ProjectGridBlockRenderer({
56
+ block,
57
+ }: {
58
+ block: ProjectGridBlock;
59
+ }) {
60
+ const assetUrl = useAssetUrl();
61
+ const thumbUrl = useThumbUrl();
62
+ const viewport = useViewport();
63
+ const containerRef = useRef<HTMLDivElement>(null);
64
+ const roRef = useRef<ResizeObserver | null>(null);
65
+ /** Container top offset at first measure — for above-the-fold detection */
66
+ const containerTopRef = useRef<number | null>(null);
67
+ const [containerWidth, setContainerWidth] = useState(0);
68
+ const [resolvedProjects, setResolvedProjects] = useState<ResolvedProject[]>([]);
69
+
70
+ // ─── Measure container width via ResizeObserver ───
71
+ // Uses callback ref so the observer is set up the instant the DOM node
72
+ // is attached, with a rAF retry for delayed-layout cases.
73
+ const containerCallbackRef = useCallback((node: HTMLDivElement | null) => {
74
+ if (roRef.current) {
75
+ roRef.current.disconnect();
76
+ roRef.current = null;
77
+ }
78
+ containerRef.current = node;
79
+ if (!node) return;
80
+
81
+ const ro = new ResizeObserver((entries) => {
82
+ for (const entry of entries) {
83
+ const w = entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
84
+ if (w > 0) setContainerWidth(w);
85
+ }
86
+ // Capture container top on first observation (for above-the-fold detection)
87
+ if (containerTopRef.current === null) {
88
+ containerTopRef.current = node.getBoundingClientRect().top;
89
+ }
90
+ });
91
+ roRef.current = ro;
92
+ ro.observe(node);
93
+
94
+ // Immediate + rAF fallback
95
+ const measure = () => {
96
+ const w = node.clientWidth;
97
+ if (w > 0) setContainerWidth(w);
98
+ };
99
+ measure();
100
+ requestAnimationFrame(measure);
101
+ }, []);
102
+
103
+ useEffect(
104
+ () => () => {
105
+ if (roRef.current) {
106
+ roRef.current.disconnect();
107
+ roRef.current = null;
108
+ }
109
+ },
110
+ [],
111
+ );
112
+
113
+ // ─── Fetch project data ───
114
+ useEffect(() => {
115
+ if (!block.projects?.length) return;
116
+
117
+ const slugs = block.projects.map((p) => p.project_slug);
118
+
119
+ fetch("/api/projects")
120
+ .then((r) => (r.ok ? r.json() : { projects: [] }))
121
+ .then((data) => {
122
+ const projectMap = new Map<string, { title: string; subtitle: string; thumbnail_path?: string; cover_video?: string }>();
123
+ for (const proj of data.projects || []) {
124
+ const projSlug = proj.slug?.current || proj.slug;
125
+ if (projSlug && slugs.includes(projSlug)) {
126
+ projectMap.set(projSlug, {
127
+ title: proj.title,
128
+ subtitle: proj.seo_description || "",
129
+ thumbnail_path: proj.thumbnail_path,
130
+ cover_video: proj.cover_video,
131
+ });
132
+ }
133
+ }
134
+
135
+ const resolved = block.projects
136
+ .map((item) => {
137
+ const proj = projectMap.get(item.project_slug);
138
+ if (!proj) return null;
139
+ return {
140
+ slug: item.project_slug,
141
+ _key: item._key,
142
+ title: proj.title,
143
+ subtitle: item.custom_subtitle || proj.subtitle,
144
+ thumbnail_path: item.custom_thumbnail || proj.thumbnail_path,
145
+ cover_video: proj.cover_video,
146
+ aspect_ratio_override: item.aspect_ratio_override || null,
147
+ responsive: item.responsive,
148
+ };
149
+ })
150
+ .filter(Boolean) as ResolvedProject[];
151
+
152
+ setResolvedProjects(resolved);
153
+ })
154
+ .catch(() => {
155
+ setResolvedProjects([]);
156
+ });
157
+ }, [block.projects]);
158
+
159
+ // ─── Grid config (already resolved for current viewport by BlockRenderer) ───
160
+ const columns = block.columns || 3;
161
+ const gapV = block.gap_v ?? 16;
162
+ const gapH = block.gap_h ?? 16;
163
+ const aspectRatios = block.aspect_ratios?.length ? block.aspect_ratios : ["16/9"];
164
+ const hoverEffect = block.hover_effect || "scale";
165
+ const showSubtitle = block.show_subtitle !== false;
166
+ const borderRadius = block.border_radius || 0;
167
+ const videoMode = block.video_mode || "off";
168
+
169
+ // ─── Build masonry items (viewport-aware per-card overrides) ───
170
+ const masonryItems: MasonryItem[] = useMemo(() => {
171
+ return resolvedProjects.map((proj, i) => {
172
+ let override = proj.aspect_ratio_override;
173
+ if (viewport !== "desktop") {
174
+ const vpOverride = proj.responsive?.[viewport]?.aspect_ratio_override;
175
+ if (vpOverride !== undefined) override = vpOverride;
176
+ }
177
+ return {
178
+ key: proj._key,
179
+ aspectRatio: resolveItemRatio(i, override, {
180
+ gridRatios: aspectRatios,
181
+ }),
182
+ };
183
+ });
184
+ }, [resolvedProjects, aspectRatios, viewport]);
185
+
186
+ // ─── Compute masonry layout ───
187
+ const masonry: MasonryOutput = useMemo(() => {
188
+ if (containerWidth <= 0 || masonryItems.length === 0) {
189
+ return { items: [], totalHeight: 0 };
190
+ }
191
+ return computeMasonry(masonryItems, {
192
+ columns,
193
+ gapH,
194
+ gapV,
195
+ containerWidth,
196
+ });
197
+ }, [masonryItems, columns, gapH, gapV, containerWidth]);
198
+
199
+ // ─── Map masonry results to projects ───
200
+ const projectByKey = useMemo(() => {
201
+ const map = new Map<string, ResolvedProject>();
202
+ for (const proj of resolvedProjects) {
203
+ map.set(proj._key, proj);
204
+ }
205
+ return map;
206
+ }, [resolvedProjects]);
207
+
208
+ // ─── Card entrance animation config ───
209
+ const entrance = block.card_entrance;
210
+ const entranceEnabled = entrance?.enabled ?? false;
211
+ const entrancePreset = entrance?.preset || "slide-up";
212
+ const entranceStaggerDelay = entrance?.stagger_delay ?? 80;
213
+ const entranceDuration = entrance?.duration ?? 500;
214
+
215
+ // Map card_entrance presets to ResolvedEnterAnimationConfig
216
+ const entranceAnimConfig: ResolvedEnterAnimationConfig | undefined = entranceEnabled
217
+ ? {
218
+ preset: entrancePreset === "slide-up" ? "slide-up" : entrancePreset === "scale" ? "scale" : "fade",
219
+ duration: entranceDuration,
220
+ easing: "ease-out",
221
+ delay: 0,
222
+ }
223
+ : undefined;
224
+
225
+ // ─── Determine which cards are above the fold (visible without scrolling) ───
226
+ // Only these cards get the entrance animation. Cards below the fold
227
+ // render immediately visible to avoid empty gaps while scrolling.
228
+ const aboveFoldKeys = useMemo(() => {
229
+ if (!entranceEnabled || masonry.items.length === 0) return new Set<string>();
230
+
231
+ const containerTop = containerTopRef.current ?? 0;
232
+ const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 900;
233
+ // How much vertical space is available below the container's top edge
234
+ const cutoff = viewportHeight - containerTop;
235
+
236
+ const keys = new Set<string>();
237
+ for (const item of masonry.items) {
238
+ // Card is above the fold if its top edge starts within the viewport
239
+ if (item.y < cutoff) {
240
+ keys.add(item.key);
241
+ }
242
+ }
243
+ return keys;
244
+ }, [entranceEnabled, masonry.items]);
245
+
246
+ // ─── Compute stagger indices sorted by vertical position ───
247
+ // Cards at similar y positions (within half of gapV) share the same rank.
248
+ // Only computed for above-the-fold cards.
249
+ const staggerIndices = useMemo(() => {
250
+ if (!entranceEnabled || masonry.items.length === 0) return new Map<string, number>();
251
+
252
+ // Only stagger above-the-fold cards
253
+ const aboveFold = masonry.items.filter((item) => aboveFoldKeys.has(item.key));
254
+ if (aboveFold.length === 0) return new Map<string, number>();
255
+
256
+ // Sort items by y, then by x for tie-breaking
257
+ const sorted = [...aboveFold].sort((a, b) => a.y - b.y || a.x - b.x);
258
+ const indices = new Map<string, number>();
259
+ let rank = 0;
260
+ let prevY = -Infinity;
261
+ const yThreshold = Math.max(gapV * 0.5, 4); // tolerance for "same row"
262
+
263
+ for (const item of sorted) {
264
+ if (item.y - prevY > yThreshold) {
265
+ if (prevY !== -Infinity) rank++;
266
+ prevY = item.y;
267
+ }
268
+ indices.set(item.key, rank);
269
+ }
270
+ return indices;
271
+ }, [entranceEnabled, masonry.items, aboveFoldKeys, gapV]);
272
+
273
+ if (resolvedProjects.length === 0) return null;
274
+
275
+ return (
276
+ <div
277
+ style={{
278
+ maxWidth: "var(--grid-width, 1445px)",
279
+ marginLeft: "auto",
280
+ marginRight: "auto",
281
+ width: "100%",
282
+ }}
283
+ >
284
+ <div
285
+ ref={containerCallbackRef}
286
+ style={{
287
+ position: "relative",
288
+ height: masonry.totalHeight > 0 ? masonry.totalHeight : undefined,
289
+ // Reserve minimum height while measuring
290
+ minHeight: masonry.totalHeight > 0 ? undefined : 100,
291
+ }}
292
+ >
293
+ {masonry.items.map((item) => {
294
+ const proj = projectByKey.get(item.key);
295
+ if (!proj) return null;
296
+
297
+ const card = (
298
+ <ProjectCard
299
+ project={proj}
300
+ hoverEffect={hoverEffect}
301
+ showSubtitle={showSubtitle}
302
+ borderRadius={borderRadius}
303
+ videoMode={videoMode}
304
+ assetUrl={assetUrl}
305
+ thumbUrl={thumbUrl}
306
+ />
307
+ );
308
+
309
+ const isAboveFold = aboveFoldKeys.has(item.key);
310
+
311
+ return (
312
+ <div
313
+ key={item.key}
314
+ data-card-entrance={entranceEnabled && isAboveFold ? "" : undefined}
315
+ style={{
316
+ position: "absolute",
317
+ left: item.x,
318
+ top: item.y,
319
+ width: item.width,
320
+ height: item.height,
321
+ }}
322
+ >
323
+ {entranceEnabled && entranceAnimConfig && isAboveFold ? (
324
+ <EnterAnimationWrapper
325
+ config={entranceAnimConfig}
326
+ staggerIndex={staggerIndices.get(item.key) ?? 0}
327
+ staggerDelay={entranceStaggerDelay}
328
+ style={{ width: "100%", height: "100%" }}
329
+ >
330
+ {card}
331
+ </EnterAnimationWrapper>
332
+ ) : (
333
+ card
334
+ )}
335
+ </div>
336
+ );
337
+ })}
338
+ </div>
339
+ </div>
340
+ );
341
+ }
342
+
343
+ // ─── Project Card ───
344
+
345
+ const ProjectCard = memo(function ProjectCard({
346
+ project,
347
+ hoverEffect,
348
+ showSubtitle,
349
+ borderRadius,
350
+ videoMode,
351
+ assetUrl,
352
+ thumbUrl,
353
+ }: {
354
+ project: ResolvedProject;
355
+ hoverEffect: "3d" | "scale" | "none";
356
+ showSubtitle: boolean;
357
+ borderRadius: number;
358
+ videoMode: "off" | "hover" | "autoloop";
359
+ assetUrl: (path: string) => string;
360
+ thumbUrl: (path: string) => string;
361
+ }) {
362
+ const [hovered, setHovered] = useState(false);
363
+ const [isPlaying, setIsPlaying] = useState(false);
364
+ const videoRef = useRef<HTMLVideoElement>(null);
365
+ const cardRef = useRef<HTMLDivElement>(null);
366
+
367
+ const imgSrc = project.thumbnail_path ? thumbUrl(project.thumbnail_path) : "";
368
+ const rawVideoSrc = project.cover_video ? assetUrl(project.cover_video) : "";
369
+ const videoSrc = videoMode !== "off" ? rawVideoSrc : "";
370
+ const videoActive = videoSrc && isPlaying;
371
+
372
+ const radius = borderRadius > 0 ? borderRadius : undefined;
373
+
374
+ // ─── 3D tilt handler ───
375
+ const handleMouseMove = useCallback(
376
+ (e: React.MouseEvent) => {
377
+ if (hoverEffect !== "3d" || !cardRef.current) return;
378
+ const rect = cardRef.current.getBoundingClientRect();
379
+ const x = (e.clientX - rect.left) / rect.width;
380
+ const y = (e.clientY - rect.top) / rect.height;
381
+ const rotateY = (x - 0.5) * 20; // -10 to +10 deg
382
+ const rotateX = (0.5 - y) * 20;
383
+ cardRef.current.style.transform = `perspective(600px) rotateX(${rotateX}deg) rotateY(${rotateY}deg)`;
384
+ },
385
+ [hoverEffect],
386
+ );
387
+
388
+ // ─── Hover enter/leave ───
389
+ const handleMouseEnter = useCallback(() => {
390
+ setHovered(true);
391
+ if (videoMode === "hover" && videoRef.current) {
392
+ videoRef.current.play().catch(() => {});
393
+ }
394
+ }, [videoMode]);
395
+
396
+ const handleMouseLeave = useCallback(() => {
397
+ setHovered(false);
398
+ if (videoMode === "hover" && videoRef.current) {
399
+ videoRef.current.pause();
400
+ videoRef.current.currentTime = 0;
401
+ }
402
+ // Reset 3D tilt
403
+ if (hoverEffect === "3d" && cardRef.current) {
404
+ cardRef.current.style.transform = "perspective(600px) rotateX(0deg) rotateY(0deg)";
405
+ }
406
+ }, [videoMode, hoverEffect]);
407
+
408
+ // Autoloop: play on canplay
409
+ const handleVideoCanPlay = useCallback(() => {
410
+ if (videoMode === "autoloop" && videoRef.current) {
411
+ videoRef.current.play().catch(() => {});
412
+ }
413
+ }, [videoMode]);
414
+
415
+ const handlePlay = useCallback(() => setIsPlaying(true), []);
416
+ const handlePause = useCallback(() => setIsPlaying(false), []);
417
+
418
+ // Scale transform
419
+ const scaleTransform =
420
+ hoverEffect === "scale" && hovered ? "scale(1.03)" : "scale(1)";
421
+
422
+ return (
423
+ <Link
424
+ href={`/work/${project.slug}`}
425
+ className="block"
426
+ style={{ display: "block", width: "100%", height: "100%" }}
427
+ onMouseEnter={handleMouseEnter}
428
+ onMouseLeave={handleMouseLeave}
429
+ onMouseMove={hoverEffect === "3d" ? handleMouseMove : undefined}
430
+ >
431
+ <div
432
+ ref={cardRef}
433
+ style={{
434
+ position: "relative",
435
+ width: "100%",
436
+ height: "100%",
437
+ overflow: "hidden",
438
+ borderRadius: radius,
439
+ transition:
440
+ hoverEffect === "3d"
441
+ ? "transform 100ms ease-out"
442
+ : hoverEffect === "scale"
443
+ ? "transform 300ms ease"
444
+ : undefined,
445
+ transform: hoverEffect === "scale" ? scaleTransform : undefined,
446
+ }}
447
+ >
448
+ {/* Thumbnail image */}
449
+ {imgSrc ? (
450
+ <img
451
+ src={imgSrc}
452
+ alt={project.title}
453
+ style={{
454
+ position: "absolute",
455
+ inset: 0,
456
+ width: "100%",
457
+ height: "100%",
458
+ objectFit: "cover",
459
+ opacity: videoActive ? 0 : 1,
460
+ transition: "opacity 0.4s ease",
461
+ }}
462
+ onError={(e) => {
463
+ const target = e.currentTarget;
464
+ if (project.thumbnail_path && !target.dataset.fallback) {
465
+ const fallbackSrc = assetUrl(project.thumbnail_path);
466
+ if (fallbackSrc !== target.src) {
467
+ target.dataset.fallback = "1";
468
+ target.src = fallbackSrc;
469
+ }
470
+ }
471
+ }}
472
+ />
473
+ ) : (
474
+ <div
475
+ style={{
476
+ position: "absolute",
477
+ inset: 0,
478
+ display: "flex",
479
+ alignItems: "center",
480
+ justifyContent: "center",
481
+ background: "#1a1a1a",
482
+ }}
483
+ >
484
+ <span style={{ fontSize: 12, color: "#666" }}>No thumbnail</span>
485
+ </div>
486
+ )}
487
+
488
+ {/* Cover video */}
489
+ {videoSrc && (
490
+ <video
491
+ ref={videoRef}
492
+ src={videoSrc}
493
+ style={{
494
+ position: "absolute",
495
+ inset: 0,
496
+ width: "100%",
497
+ height: "100%",
498
+ objectFit: "cover",
499
+ opacity: videoActive ? 1 : 0,
500
+ transition: "opacity 0.4s ease",
501
+ }}
502
+ muted
503
+ loop
504
+ playsInline
505
+ preload={videoMode === "autoloop" ? "auto" : "metadata"}
506
+ onCanPlay={handleVideoCanPlay}
507
+ onPlay={handlePlay}
508
+ onPause={handlePause}
509
+ />
510
+ )}
511
+
512
+ {/* Subtitle overlay */}
513
+ {showSubtitle && (
514
+ <div
515
+ style={{
516
+ position: "absolute",
517
+ bottom: 0,
518
+ left: 0,
519
+ right: 0,
520
+ padding: "32px 16px 12px",
521
+ background: "linear-gradient(to top, rgba(0,0,0,0.6) 0%, transparent 100%)",
522
+ pointerEvents: "none",
523
+ }}
524
+ >
525
+ <h3
526
+ style={{
527
+ margin: 0,
528
+ fontSize: 14,
529
+ fontWeight: 500,
530
+ color: "#fff",
531
+ lineHeight: 1.3,
532
+ }}
533
+ >
534
+ {project.title}
535
+ </h3>
536
+ {project.subtitle && (
537
+ <p
538
+ style={{
539
+ margin: "2px 0 0",
540
+ fontSize: 12,
541
+ color: "rgba(255,255,255,0.7)",
542
+ lineHeight: 1.3,
543
+ overflow: "hidden",
544
+ textOverflow: "ellipsis",
545
+ whiteSpace: "nowrap",
546
+ }}
547
+ >
548
+ {project.subtitle}
549
+ </p>
550
+ )}
551
+ </div>
552
+ )}
553
+ </div>
554
+ </Link>
555
+ );
556
+ });