@morphika/webframe 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +46 -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 +210 -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,1010 @@
1
+ "use client";
2
+
3
+ /**
4
+ * LiveProjectGridPreview — Builder canvas preview for ProjectGrid v2.
5
+ *
6
+ * Uses the shared masonry engine (lib/builder/masonry.ts) with absolute
7
+ * positioning for pixel-perfect parity with the public renderer.
8
+ *
9
+ * Features:
10
+ * - JS masonry layout (shortest-column algorithm)
11
+ * - 1–6 columns, per-card aspect ratio overrides
12
+ * - Card selection (click → blue border, settings panel shows per-card controls)
13
+ * - Hover state (blue border, suppressed during drag)
14
+ * - Override badge (aspect ratio override indicator)
15
+ * - Custom drag & drop: hold card body (150ms) or grab handle (immediate)
16
+ * → grabbed state (darkened + icon) → dragging (ghost follows mouse,
17
+ * dashed placeholder, green drop targets via coordinate hit-testing)
18
+ * → swap on drop or cancel fly-back animation
19
+ *
20
+ * Session 105 Phase 4: Builder live preview rewrite.
21
+ * Session 105 Phase 5: Custom DnD system (drag initiation, ghost, coordinate
22
+ * hit-testing for drop targets, swap logic, cancel animation).
23
+ */
24
+
25
+ import {
26
+ useState,
27
+ useCallback,
28
+ useRef,
29
+ useEffect,
30
+ useMemo,
31
+ } from "react";
32
+ import { createPortal } from "react-dom";
33
+ import { ProjectGridCard, useProjectThumbnails } from "./shared";
34
+ import { useBuilderStore } from "../../../lib/builder/store";
35
+ import {
36
+ computeMasonry,
37
+ resolveItemRatio,
38
+ type MasonryItem,
39
+ type MasonryOutput,
40
+ } from "../../../lib/builder/masonry";
41
+ import type { ProjectGridBlock, ProjectGridItem } from "../../../lib/sanity/types";
42
+ import type { DeviceViewport } from "../../../lib/builder/types";
43
+ import { BUILDER_BLUE, BUILDER_GREEN } from "../../../lib/builder/constants";
44
+
45
+ // ─── Constants ───────────────────────────────────────────────────────
46
+
47
+ const HOLD_DELAY = 150; // ms before card-body drag activates
48
+ const MOVE_THRESHOLD_SQ = 9; // 3px² — grabbed → dragging
49
+ const CANCEL_DURATION = 200; // ms — cancel fly-back animation
50
+ const ADMIN_BLUE = BUILDER_BLUE;
51
+ const DROP_GREEN = BUILDER_GREEN;
52
+
53
+ // ─── Types ───────────────────────────────────────────────────────────
54
+
55
+ interface DragState {
56
+ phase: "grabbed" | "dragging" | "cancelling";
57
+ draggedKey: string;
58
+ hoverTargetKey: string | null;
59
+ mouseX: number;
60
+ mouseY: number;
61
+ startMouseX: number;
62
+ startMouseY: number;
63
+ offsetX: number; // grab offset inside the card (screen px)
64
+ offsetY: number;
65
+ cardWidth: number; // screen-space dimensions (includes zoom)
66
+ cardHeight: number;
67
+ origScreenX: number; // original card position on screen (for cancel)
68
+ origScreenY: number;
69
+ }
70
+
71
+ // ─── Utilities ───────────────────────────────────────────────────────
72
+
73
+ /** Cross-arrow icon SVG (reused in handle, grabbed overlay, ghost). */
74
+ function CrossArrowIcon({
75
+ size = 14,
76
+ color = "currentColor",
77
+ }: {
78
+ size?: number;
79
+ color?: string;
80
+ }) {
81
+ return (
82
+ <svg width={size} height={size} viewBox="0 0 16 16" fill={color}>
83
+ <path d="M8 0l2.5 3h-2v4.5H13v-2L16 8l-3 2.5v-2H8.5V13h2L8 16l-2.5-3h2V8.5H3v2L0 8l3-2.5v2h4.5V3h-2L8 0z" />
84
+ </svg>
85
+ );
86
+ }
87
+
88
+ /**
89
+ * Hit-test pointer (screen coords) against masonry items (container coords).
90
+ * Returns the key of the card under the cursor, or null if in a gap.
91
+ * Only ONE card can match (masonry cards never overlap).
92
+ */
93
+ function hitTestCards(
94
+ clientX: number,
95
+ clientY: number,
96
+ container: HTMLElement,
97
+ containerWidth: number,
98
+ masonry: MasonryOutput,
99
+ excludeKey: string,
100
+ ): string | null {
101
+ const rect = container.getBoundingClientRect();
102
+ if (rect.width === 0 || rect.height === 0 || masonry.totalHeight === 0)
103
+ return null;
104
+
105
+ // Convert screen → masonry coordinate space
106
+ const relX = (clientX - rect.left) * (containerWidth / rect.width);
107
+ const relY = (clientY - rect.top) * (masonry.totalHeight / rect.height);
108
+
109
+ for (const item of masonry.items) {
110
+ if (item.key === excludeKey) continue;
111
+ if (
112
+ relX >= item.x &&
113
+ relX <= item.x + item.width &&
114
+ relY >= item.y &&
115
+ relY <= item.y + item.height
116
+ ) {
117
+ return item.key;
118
+ }
119
+ }
120
+ return null;
121
+ }
122
+
123
+ // ─── ProjectCardWrapper ─────────────────────────────────────────────
124
+
125
+ interface CardProps {
126
+ item: ProjectGridItem;
127
+ thumbMap: Map<string, string | undefined>;
128
+ borderRadius: number;
129
+ cardWidth: number;
130
+ cardHeight: number;
131
+ isGrabbed: boolean;
132
+ isDragging: boolean;
133
+ isDropTarget: boolean;
134
+ isSelected: boolean;
135
+ isAnyDragActive: boolean;
136
+ onPointerDown: (
137
+ key: string,
138
+ e: React.PointerEvent,
139
+ cardEl: HTMLDivElement,
140
+ fromHandle: boolean,
141
+ ) => void;
142
+ onSelect: (key: string) => void;
143
+ }
144
+
145
+ function ProjectCardWrapper({
146
+ item,
147
+ thumbMap,
148
+ borderRadius,
149
+ cardWidth,
150
+ cardHeight,
151
+ isGrabbed,
152
+ isDragging,
153
+ isDropTarget,
154
+ isSelected,
155
+ isAnyDragActive,
156
+ onPointerDown,
157
+ onSelect,
158
+ }: CardProps) {
159
+ const cardRef = useRef<HTMLDivElement>(null);
160
+ const canvasZoom = useBuilderStore((s) => s.canvasZoom);
161
+ const [isHovered, setIsHovered] = useState(false);
162
+
163
+ // ── Handle pointerdown (drag handle = immediate, card body = hold) ──
164
+
165
+ const handleHandleDown = useCallback(
166
+ (e: React.PointerEvent) => {
167
+ e.preventDefault();
168
+ e.stopPropagation();
169
+ if (cardRef.current) onPointerDown(item._key, e, cardRef.current, true);
170
+ },
171
+ [item._key, onPointerDown],
172
+ );
173
+
174
+ const handleCardDown = useCallback(
175
+ (e: React.PointerEvent) => {
176
+ if (e.button !== 0) return;
177
+ e.stopPropagation();
178
+ if (cardRef.current) onPointerDown(item._key, e, cardRef.current, false);
179
+ },
180
+ [item._key, onPointerDown],
181
+ );
182
+
183
+ const handleClick = useCallback(
184
+ (e: React.MouseEvent) => {
185
+ e.stopPropagation();
186
+ onSelect(item._key);
187
+ },
188
+ [item._key, onSelect],
189
+ );
190
+
191
+ const br = borderRadius > 0 ? borderRadius : undefined;
192
+ const brStr = borderRadius > 0 ? String(borderRadius) : undefined;
193
+ const invZoom = Math.min(2, 1 / canvasZoom);
194
+
195
+ // ── Grabbed: darkened card + centered cross-arrow icon ──────────
196
+
197
+ if (isGrabbed) {
198
+ return (
199
+ <div
200
+ ref={cardRef}
201
+ style={{
202
+ position: "relative",
203
+ width: cardWidth,
204
+ height: cardHeight,
205
+ borderRadius: br,
206
+ overflow: "hidden",
207
+ outline: `2px solid ${ADMIN_BLUE}`,
208
+ outlineOffset: -2,
209
+ }}
210
+ >
211
+ <ProjectGridCard
212
+ slug={item.project_slug}
213
+ thumbPath={thumbMap.get(item.project_slug)}
214
+ customThumb={item.custom_thumbnail}
215
+ borderRadius={brStr}
216
+ style={{ width: "100%", height: "100%", filter: "brightness(0.65)" }}
217
+ />
218
+ {/* Centered drag icon overlay */}
219
+ <div
220
+ style={{
221
+ position: "absolute",
222
+ inset: 0,
223
+ display: "flex",
224
+ alignItems: "center",
225
+ justifyContent: "center",
226
+ pointerEvents: "none",
227
+ }}
228
+ >
229
+ <div
230
+ style={{
231
+ width: 40,
232
+ height: 40,
233
+ borderRadius: "50%",
234
+ backgroundColor: "rgba(255,255,255,0.92)",
235
+ display: "flex",
236
+ alignItems: "center",
237
+ justifyContent: "center",
238
+ boxShadow: "0 2px 12px rgba(0,0,0,0.2)",
239
+ transform: `scale(${invZoom})`,
240
+ }}
241
+ >
242
+ <CrossArrowIcon size={20} color="#333" />
243
+ </div>
244
+ </div>
245
+ </div>
246
+ );
247
+ }
248
+
249
+ // ── Dragging / Cancelling: dashed blue placeholder ─────────────
250
+
251
+ if (isDragging) {
252
+ return (
253
+ <div
254
+ ref={cardRef}
255
+ style={{
256
+ width: cardWidth,
257
+ height: cardHeight,
258
+ borderRadius: br,
259
+ border: `${Math.max(2, 2 / canvasZoom)}px dashed ${ADMIN_BLUE}`,
260
+ backgroundColor: "transparent",
261
+ boxSizing: "border-box",
262
+ }}
263
+ />
264
+ );
265
+ }
266
+
267
+ // ── Normal card ────────────────────────────────────────────────
268
+
269
+ // Green drop-target highlight — no extra style on wrapper, overlay rendered below
270
+ const dropStyle: React.CSSProperties = {};
271
+
272
+ // Blue hover border (suppressed when drag is active or card is selected/drop target)
273
+ const showHover = isHovered && !isSelected && !isDropTarget && !isAnyDragActive;
274
+ const hoverStyle: React.CSSProperties = showHover
275
+ ? {
276
+ outline: `${2 / canvasZoom}px solid ${ADMIN_BLUE}`,
277
+ outlineOffset: -2 / canvasZoom,
278
+ }
279
+ : {};
280
+
281
+ // Blue selection border + subtle tint
282
+ const selectStyle: React.CSSProperties =
283
+ isSelected && !isDropTarget
284
+ ? {
285
+ outline: `2px solid ${ADMIN_BLUE}`,
286
+ outlineOffset: -2,
287
+ boxShadow: "inset 0 0 0 9999px rgba(7,107,255,0.04)",
288
+ }
289
+ : {};
290
+
291
+ return (
292
+ <div
293
+ ref={cardRef}
294
+ style={{
295
+ position: "relative",
296
+ width: cardWidth,
297
+ height: cardHeight,
298
+ cursor: "pointer",
299
+ borderRadius: br,
300
+ overflow: "hidden",
301
+ ...dropStyle,
302
+ ...hoverStyle,
303
+ ...selectStyle,
304
+ transition: "outline 150ms ease, box-shadow 150ms ease",
305
+ }}
306
+ onPointerDown={handleCardDown}
307
+ onClick={handleClick}
308
+ onMouseEnter={() => setIsHovered(true)}
309
+ onMouseLeave={() => setIsHovered(false)}
310
+ >
311
+ {/* Drag handle — centered, visible on hover/selection (hidden during drag) */}
312
+ <div
313
+ className={`absolute z-10 transition-opacity ${
314
+ (isHovered || isSelected) && !isAnyDragActive
315
+ ? "opacity-100"
316
+ : "opacity-0 pointer-events-none"
317
+ }`}
318
+ style={{
319
+ inset: 0,
320
+ display: "flex",
321
+ alignItems: "center",
322
+ justifyContent: "center",
323
+ }}
324
+ >
325
+ <div
326
+ onPointerDown={handleHandleDown}
327
+ onClick={(e) => e.stopPropagation()}
328
+ style={{
329
+ width: 48,
330
+ height: 48,
331
+ borderRadius: "50%",
332
+ backgroundColor: "rgba(255,255,255,0.92)",
333
+ display: "flex",
334
+ alignItems: "center",
335
+ justifyContent: "center",
336
+ boxShadow: "0 2px 12px rgba(0,0,0,0.25)",
337
+ cursor: "grab",
338
+ transform: `scale(${invZoom})`,
339
+ }}
340
+ title="Drag to reorder"
341
+ aria-label="Drag to reorder project"
342
+ >
343
+ <CrossArrowIcon size={22} color="#333" />
344
+ </div>
345
+ </div>
346
+
347
+ {/* Per-card aspect ratio override badge — bottom-right */}
348
+ {item.aspect_ratio_override && (
349
+ <div
350
+ className="absolute z-10"
351
+ style={{
352
+ bottom: 8,
353
+ right: 8,
354
+ transform: `scale(${invZoom})`,
355
+ transformOrigin: "bottom right",
356
+ }}
357
+ >
358
+ <span
359
+ className="px-1.5 py-0.5 rounded text-[9px] font-medium"
360
+ style={{
361
+ backgroundColor: "rgba(0,0,0,0.6)",
362
+ color: "rgba(255,255,255,0.85)",
363
+ backdropFilter: "blur(4px)",
364
+ }}
365
+ >
366
+ {item.aspect_ratio_override.replace("/", ":")}
367
+ </span>
368
+ </div>
369
+ )}
370
+
371
+ <ProjectGridCard
372
+ slug={item.project_slug}
373
+ thumbPath={thumbMap.get(item.project_slug)}
374
+ customThumb={item.custom_thumbnail}
375
+ borderRadius={brStr}
376
+ style={{ width: "100%", height: "100%" }}
377
+ />
378
+
379
+ {/* Green drop-target overlay (covers the image) */}
380
+ {isDropTarget && (
381
+ <div
382
+ style={{
383
+ position: "absolute",
384
+ inset: 0,
385
+ backgroundColor: "rgba(34,197,94,0.30)",
386
+ borderRadius: br,
387
+ pointerEvents: "none",
388
+ zIndex: 5,
389
+ border: `2px solid ${DROP_GREEN}`,
390
+ }}
391
+ />
392
+ )}
393
+ </div>
394
+ );
395
+ }
396
+
397
+ // ─── GhostCard ──────────────────────────────────────────────────────
398
+
399
+ function GhostCard({
400
+ item,
401
+ thumbMap,
402
+ borderRadius,
403
+ dragState,
404
+ }: {
405
+ item: ProjectGridItem;
406
+ thumbMap: Map<string, string | undefined>;
407
+ borderRadius: number;
408
+ dragState: DragState;
409
+ }) {
410
+ const isCancelling = dragState.phase === "cancelling";
411
+ return (
412
+ <div
413
+ style={{
414
+ position: "fixed",
415
+ left: dragState.mouseX - dragState.offsetX,
416
+ top: dragState.mouseY - dragState.offsetY,
417
+ width: dragState.cardWidth,
418
+ height: dragState.cardHeight,
419
+ zIndex: 9999,
420
+ pointerEvents: "none",
421
+ borderRadius: borderRadius > 0 ? borderRadius : 8,
422
+ overflow: "hidden",
423
+ transform: isCancelling ? "scale(1)" : "scale(1.03)",
424
+ outline: `2px solid ${ADMIN_BLUE}`,
425
+ outlineOffset: -2,
426
+ boxShadow: isCancelling
427
+ ? "0 4px 16px rgba(0,0,0,0.15)"
428
+ : "0 16px 48px rgba(0,0,0,0.3)",
429
+ transition: isCancelling
430
+ ? `left ${CANCEL_DURATION}ms ease, top ${CANCEL_DURATION}ms ease, transform ${CANCEL_DURATION}ms ease, box-shadow ${CANCEL_DURATION}ms ease`
431
+ : undefined,
432
+ }}
433
+ >
434
+ <ProjectGridCard
435
+ slug={item.project_slug}
436
+ thumbPath={thumbMap.get(item.project_slug)}
437
+ customThumb={item.custom_thumbnail}
438
+ borderRadius={borderRadius > 0 ? String(borderRadius) : undefined}
439
+ style={{ width: "100%", height: "100%" }}
440
+ />
441
+ {/* Centered cross-arrow icon */}
442
+ <div
443
+ style={{
444
+ position: "absolute",
445
+ inset: 0,
446
+ display: "flex",
447
+ alignItems: "center",
448
+ justifyContent: "center",
449
+ pointerEvents: "none",
450
+ }}
451
+ >
452
+ <div
453
+ style={{
454
+ width: 40,
455
+ height: 40,
456
+ borderRadius: "50%",
457
+ backgroundColor: "rgba(255,255,255,0.92)",
458
+ display: "flex",
459
+ alignItems: "center",
460
+ justifyContent: "center",
461
+ boxShadow: "0 2px 12px rgba(0,0,0,0.2)",
462
+ }}
463
+ >
464
+ <CrossArrowIcon size={20} color="#333" />
465
+ </div>
466
+ </div>
467
+ </div>
468
+ );
469
+ }
470
+
471
+ // ─── Main Component ─────────────────────────────────────────────────
472
+
473
+ export default function LiveProjectGridPreview({
474
+ block,
475
+ viewport: frameViewport = "desktop",
476
+ }: {
477
+ block: ProjectGridBlock;
478
+ viewport?: DeviceViewport;
479
+ }) {
480
+ const updateBlock = useBuilderStore((s) => s.updateBlock);
481
+ const _pushSnapshot = useBuilderStore((s) => s._pushSnapshot);
482
+ const selectProjectCard = useBuilderStore((s) => s.selectProjectCard);
483
+ const selectedProjectCardKey = useBuilderStore((s) =>
484
+ s.selectedProjectCardKey,
485
+ );
486
+ const selectedBlockKey = useBuilderStore((s) => s.selectedBlockKey);
487
+
488
+ const containerRef = useRef<HTMLDivElement>(null);
489
+ const roRef = useRef<ResizeObserver | null>(null);
490
+ const [containerWidth, setContainerWidth] = useState(0);
491
+ const [dragState, setDragState] = useState<DragState | null>(null);
492
+
493
+ const activeViewport = useBuilderStore((s) => s.activeViewport);
494
+ const isThisBlockSelected = selectedBlockKey === block._key;
495
+
496
+ // ── Grid config ───────────────────────────────────────────────
497
+
498
+ const columns = block.columns || 3;
499
+ const gapV = block.gap_v ?? 16;
500
+ const gapH = block.gap_h ?? 16;
501
+ const aspectRatios = block.aspect_ratios?.length
502
+ ? block.aspect_ratios
503
+ : ["16/9"];
504
+ const borderRadius = block.border_radius || 0;
505
+ const projects = block.projects || [];
506
+ const slugs = useMemo(
507
+ () => projects.map((p) => p.project_slug),
508
+ [projects],
509
+ );
510
+ const thumbMap = useProjectThumbnails(slugs);
511
+
512
+ // ── Stable refs for event handlers ────────────────────────────
513
+
514
+ const blockRef = useRef(block);
515
+ blockRef.current = block;
516
+ const updateBlockRef = useRef(updateBlock);
517
+ updateBlockRef.current = updateBlock;
518
+ const pushSnapshotRef = useRef(_pushSnapshot);
519
+ pushSnapshotRef.current = _pushSnapshot;
520
+ const containerWidthRef = useRef(containerWidth);
521
+ containerWidthRef.current = containerWidth;
522
+ const dragStateRef = useRef(dragState);
523
+ dragStateRef.current = dragState;
524
+
525
+ // Timer & cleanup refs
526
+ const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
527
+ const holdCleanupRef = useRef<(() => void) | null>(null);
528
+ const cancelTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
529
+ const cancelRafRef = useRef<number | null>(null);
530
+ const didDragRef = useRef(false);
531
+
532
+ // ── Measure container width via ResizeObserver ────────────────
533
+ // Uses a callback ref so the observer is set up the instant the DOM
534
+ // node is attached (avoids the stale-ref problem with useEffect+[]).
535
+ // A rAF retry loop covers the case where the initial measurement is
536
+ // 0 due to CSS containment or pending layout from the canvas transform.
537
+
538
+ const containerCallbackRef = useCallback(
539
+ (node: HTMLDivElement | null) => {
540
+ // Disconnect previous observer
541
+ if (roRef.current) {
542
+ roRef.current.disconnect();
543
+ roRef.current = null;
544
+ }
545
+
546
+ containerRef.current = node;
547
+ if (!node) return;
548
+
549
+ const ro = new ResizeObserver((entries) => {
550
+ for (const entry of entries) {
551
+ const w =
552
+ entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width;
553
+ if (w > 0) setContainerWidth(w);
554
+ }
555
+ });
556
+ roRef.current = ro;
557
+ ro.observe(node);
558
+
559
+ // Immediate measurement + rAF retry for delayed layout
560
+ const measure = () => {
561
+ const w = node.clientWidth;
562
+ if (w > 0) {
563
+ setContainerWidth(w);
564
+ }
565
+ };
566
+ measure();
567
+ // Retry once layout is flushed (covers CSS containment delay)
568
+ requestAnimationFrame(measure);
569
+ },
570
+ [],
571
+ );
572
+
573
+ // Disconnect on unmount
574
+ useEffect(
575
+ () => () => {
576
+ if (roRef.current) {
577
+ roRef.current.disconnect();
578
+ roRef.current = null;
579
+ }
580
+ },
581
+ [],
582
+ );
583
+
584
+ // ── Masonry computation ───────────────────────────────────────
585
+
586
+ const masonryItems: MasonryItem[] = useMemo(
587
+ () =>
588
+ projects.map((item, i) => {
589
+ // Resolve per-card override for the frame's viewport (not the global active one)
590
+ let override = item.aspect_ratio_override;
591
+ if (frameViewport !== "desktop") {
592
+ const vp = frameViewport as "tablet" | "phone";
593
+ const vpOverride = item.responsive?.[vp]?.aspect_ratio_override;
594
+ if (vpOverride !== undefined) override = vpOverride;
595
+ }
596
+ return {
597
+ key: item._key,
598
+ aspectRatio: resolveItemRatio(i, override, {
599
+ gridRatios: aspectRatios,
600
+ }),
601
+ };
602
+ }),
603
+ [projects, aspectRatios, frameViewport],
604
+ );
605
+
606
+ const masonry: MasonryOutput = useMemo(() => {
607
+ if (containerWidth <= 0 || masonryItems.length === 0)
608
+ return { items: [], totalHeight: 0 };
609
+ return computeMasonry(masonryItems, { columns, gapH, gapV, containerWidth });
610
+ }, [masonryItems, columns, gapH, gapV, containerWidth]);
611
+
612
+ const masonryRef = useRef(masonry);
613
+ masonryRef.current = masonry;
614
+
615
+ const masonryByKey = useMemo(() => {
616
+ const map = new Map<string, (typeof masonry.items)[0]>();
617
+ for (const item of masonry.items) map.set(item.key, item);
618
+ return map;
619
+ }, [masonry]);
620
+
621
+ // ── Drag: initiate ────────────────────────────────────────────
622
+
623
+ const initiateDrag = useCallback(
624
+ (
625
+ key: string,
626
+ clientX: number,
627
+ clientY: number,
628
+ cardEl: HTMLDivElement,
629
+ ) => {
630
+ // Clean up any pending hold
631
+ if (holdCleanupRef.current) {
632
+ holdCleanupRef.current();
633
+ holdCleanupRef.current = null;
634
+ }
635
+ // Clean up any cancel animation in progress
636
+ if (cancelTimerRef.current) {
637
+ clearTimeout(cancelTimerRef.current);
638
+ cancelTimerRef.current = null;
639
+ }
640
+ if (cancelRafRef.current) {
641
+ cancelAnimationFrame(cancelRafRef.current);
642
+ cancelRafRef.current = null;
643
+ }
644
+
645
+ const rect = cardEl.getBoundingClientRect();
646
+ didDragRef.current = true;
647
+ setDragState({
648
+ phase: "grabbed",
649
+ draggedKey: key,
650
+ hoverTargetKey: null,
651
+ mouseX: clientX,
652
+ mouseY: clientY,
653
+ startMouseX: clientX,
654
+ startMouseY: clientY,
655
+ offsetX: clientX - rect.left,
656
+ offsetY: clientY - rect.top,
657
+ cardWidth: rect.width,
658
+ cardHeight: rect.height,
659
+ origScreenX: rect.left,
660
+ origScreenY: rect.top,
661
+ });
662
+ },
663
+ [],
664
+ );
665
+
666
+ const handleDragStart = useCallback(
667
+ (
668
+ key: string,
669
+ e: React.PointerEvent,
670
+ cardEl: HTMLDivElement,
671
+ fromHandle: boolean,
672
+ ) => {
673
+ if (fromHandle) {
674
+ e.preventDefault();
675
+ initiateDrag(key, e.clientX, e.clientY, cardEl);
676
+ return;
677
+ }
678
+
679
+ // Card body → hold-to-drag
680
+ const cx = e.clientX;
681
+ const cy = e.clientY;
682
+ didDragRef.current = false;
683
+
684
+ // Clean any previous pending hold
685
+ if (holdCleanupRef.current) holdCleanupRef.current();
686
+
687
+ const cleanup = () => {
688
+ if (holdTimerRef.current) {
689
+ clearTimeout(holdTimerRef.current);
690
+ holdTimerRef.current = null;
691
+ }
692
+ window.removeEventListener("pointerup", onEarlyRelease);
693
+ window.removeEventListener("pointermove", onEarlyMove);
694
+ holdCleanupRef.current = null;
695
+ };
696
+
697
+ const onEarlyRelease = () => cleanup();
698
+
699
+ const onEarlyMove = (ev: PointerEvent) => {
700
+ // If user moved > 10px before hold time, cancel (not a drag intent)
701
+ const dx = ev.clientX - cx;
702
+ const dy = ev.clientY - cy;
703
+ if (dx * dx + dy * dy > 100) cleanup();
704
+ };
705
+
706
+ holdTimerRef.current = setTimeout(() => {
707
+ holdTimerRef.current = null;
708
+ window.removeEventListener("pointerup", onEarlyRelease);
709
+ window.removeEventListener("pointermove", onEarlyMove);
710
+ holdCleanupRef.current = null;
711
+ initiateDrag(key, cx, cy, cardEl);
712
+ }, HOLD_DELAY);
713
+
714
+ window.addEventListener("pointerup", onEarlyRelease);
715
+ window.addEventListener("pointermove", onEarlyMove);
716
+ holdCleanupRef.current = cleanup;
717
+ },
718
+ [initiateDrag],
719
+ );
720
+
721
+ // ── Drag: click handler (selection) ───────────────────────────
722
+
723
+ const handleSelect = useCallback(
724
+ (key: string) => {
725
+ // Suppress click that follows a completed drag
726
+ if (didDragRef.current) {
727
+ didDragRef.current = false;
728
+ return;
729
+ }
730
+ selectProjectCard(selectedProjectCardKey === key ? null : key);
731
+ },
732
+ [selectProjectCard, selectedProjectCardKey],
733
+ );
734
+
735
+ // ── Global pointer handlers during drag ───────────────────────
736
+
737
+ const hasDrag = dragState !== null;
738
+
739
+ useEffect(() => {
740
+ if (!hasDrag) return;
741
+
742
+ const onMove = (e: PointerEvent) => {
743
+ setDragState((prev) => {
744
+ if (!prev || prev.phase === "cancelling") return prev;
745
+
746
+ const next: DragState = {
747
+ ...prev,
748
+ mouseX: e.clientX,
749
+ mouseY: e.clientY,
750
+ };
751
+
752
+ // Transition: grabbed → dragging on sufficient movement
753
+ if (prev.phase === "grabbed") {
754
+ const dx = e.clientX - prev.startMouseX;
755
+ const dy = e.clientY - prev.startMouseY;
756
+ if (dx * dx + dy * dy > MOVE_THRESHOLD_SQ) {
757
+ next.phase = "dragging";
758
+ }
759
+ }
760
+
761
+ // Coordinate-based hit-testing for drop targets
762
+ if (next.phase === "dragging" && containerRef.current) {
763
+ next.hoverTargetKey = hitTestCards(
764
+ e.clientX,
765
+ e.clientY,
766
+ containerRef.current,
767
+ containerWidthRef.current,
768
+ masonryRef.current,
769
+ prev.draggedKey,
770
+ );
771
+ }
772
+
773
+ return next;
774
+ });
775
+ };
776
+
777
+ const onUp = () => {
778
+ const prev = dragStateRef.current;
779
+ if (!prev) {
780
+ setDragState(null);
781
+ return;
782
+ }
783
+
784
+ // Grabbed with no movement → cancel (no visual action, allow click through)
785
+ if (prev.phase === "grabbed") {
786
+ setDragState(null);
787
+ setTimeout(() => {
788
+ didDragRef.current = false;
789
+ }, 0);
790
+ return;
791
+ }
792
+
793
+ // Dragging with a valid drop target → swap
794
+ if (prev.phase === "dragging" && prev.hoverTargetKey) {
795
+ const blk = blockRef.current;
796
+ const arr = [...(blk.projects || [])];
797
+ const fromIdx = arr.findIndex((p) => p._key === prev.draggedKey);
798
+ const toIdx = arr.findIndex((p) => p._key === prev.hoverTargetKey);
799
+ if (fromIdx !== -1 && toIdx !== -1 && fromIdx !== toIdx) {
800
+ pushSnapshotRef.current();
801
+ [arr[fromIdx], arr[toIdx]] = [arr[toIdx], arr[fromIdx]];
802
+ updateBlockRef.current(blk._key, {
803
+ projects: arr,
804
+ } as Partial<ProjectGridBlock>);
805
+ }
806
+ setDragState(null);
807
+ setTimeout(() => {
808
+ didDragRef.current = false;
809
+ }, 0);
810
+ return;
811
+ }
812
+
813
+ // Dragging with no target → cancel with fly-back animation
814
+ if (prev.phase === "dragging") {
815
+ setDragState({ ...prev, phase: "cancelling", hoverTargetKey: null });
816
+ setTimeout(() => {
817
+ didDragRef.current = false;
818
+ }, 0);
819
+ return;
820
+ }
821
+
822
+ // Fallback: clear
823
+ setDragState(null);
824
+ };
825
+
826
+ window.addEventListener("pointermove", onMove);
827
+ window.addEventListener("pointerup", onUp);
828
+ return () => {
829
+ window.removeEventListener("pointermove", onMove);
830
+ window.removeEventListener("pointerup", onUp);
831
+ };
832
+ }, [hasDrag]);
833
+
834
+ // ── Cancel animation: fly ghost back to original position ─────
835
+
836
+ useEffect(() => {
837
+ if (dragState?.phase !== "cancelling") return;
838
+
839
+ // Double rAF ensures the transition CSS is painted before position change
840
+ cancelRafRef.current = requestAnimationFrame(() => {
841
+ cancelRafRef.current = requestAnimationFrame(() => {
842
+ setDragState((prev) => {
843
+ if (prev?.phase !== "cancelling") return prev;
844
+ return {
845
+ ...prev,
846
+ mouseX: prev.origScreenX + prev.offsetX,
847
+ mouseY: prev.origScreenY + prev.offsetY,
848
+ };
849
+ });
850
+ cancelRafRef.current = null;
851
+ });
852
+ });
853
+
854
+ // Clear state after transition completes
855
+ cancelTimerRef.current = setTimeout(() => {
856
+ setDragState(null);
857
+ cancelTimerRef.current = null;
858
+ }, CANCEL_DURATION + 50);
859
+
860
+ return () => {
861
+ if (cancelRafRef.current) cancelAnimationFrame(cancelRafRef.current);
862
+ if (cancelTimerRef.current) {
863
+ clearTimeout(cancelTimerRef.current);
864
+ cancelTimerRef.current = null;
865
+ }
866
+ };
867
+ }, [dragState?.phase]);
868
+
869
+ // ── Cleanup on unmount ────────────────────────────────────────
870
+
871
+ useEffect(
872
+ () => () => {
873
+ if (holdTimerRef.current) clearTimeout(holdTimerRef.current);
874
+ if (holdCleanupRef.current) holdCleanupRef.current();
875
+ if (cancelTimerRef.current) clearTimeout(cancelTimerRef.current);
876
+ if (cancelRafRef.current) cancelAnimationFrame(cancelRafRef.current);
877
+ },
878
+ [],
879
+ );
880
+
881
+ // ── Container click → clear card selection ────────────────────
882
+
883
+ const handleContainerClick = useCallback(
884
+ (e: React.MouseEvent) => {
885
+ if (e.target === e.currentTarget) selectProjectCard(null);
886
+ },
887
+ [selectProjectCard],
888
+ );
889
+
890
+ // ── Derived drag values ───────────────────────────────────────
891
+
892
+ const draggedKey = dragState?.draggedKey ?? null;
893
+ const hoverTargetKey =
894
+ dragState?.phase === "dragging" ? dragState.hoverTargetKey : null;
895
+ const dragPhase = dragState?.phase ?? null;
896
+ const isAnyDragActive = dragState !== null;
897
+ const draggedItem = dragState
898
+ ? projects.find((p) => p._key === dragState.draggedKey)
899
+ : null;
900
+
901
+ // ── Empty state ───────────────────────────────────────────────
902
+
903
+ if (projects.length === 0) {
904
+ return (
905
+ <div
906
+ className="border-2 border-dashed border-neutral-300 rounded-lg py-12 flex flex-col items-center justify-center"
907
+ style={{ minHeight: 200 }}
908
+ >
909
+ <div className="text-neutral-400 mb-2">
910
+ <svg
911
+ width="32"
912
+ height="32"
913
+ viewBox="0 0 24 24"
914
+ fill="none"
915
+ stroke="currentColor"
916
+ strokeWidth="1.5"
917
+ >
918
+ <rect x="3" y="3" width="7" height="7" />
919
+ <rect x="14" y="3" width="7" height="7" />
920
+ <rect x="3" y="14" width="7" height="7" />
921
+ <rect x="14" y="14" width="7" height="7" />
922
+ </svg>
923
+ </div>
924
+ <span className="text-xs text-neutral-400 font-medium">
925
+ Project Grid
926
+ </span>
927
+ <span className="text-[10px] text-neutral-400 mt-0.5">
928
+ Select projects in the settings panel
929
+ </span>
930
+ </div>
931
+ );
932
+ }
933
+
934
+ // ── Render ────────────────────────────────────────────────────
935
+
936
+ return (
937
+ <div ref={containerCallbackRef} onClick={handleContainerClick}>
938
+ {/* Masonry container with absolute positioning */}
939
+ <div
940
+ style={{
941
+ position: "relative",
942
+ height: masonry.totalHeight > 0 ? masonry.totalHeight : undefined,
943
+ minHeight: masonry.totalHeight > 0 ? undefined : 100,
944
+ }}
945
+ >
946
+ {projects.map((item) => {
947
+ const mr = masonryByKey.get(item._key);
948
+ if (!mr) return null;
949
+
950
+ const isThisDragged = draggedKey === item._key;
951
+ const showGrabbed = isThisDragged && dragPhase === "grabbed";
952
+ const showPlaceholder =
953
+ isThisDragged &&
954
+ (dragPhase === "dragging" || dragPhase === "cancelling");
955
+
956
+ return (
957
+ <div
958
+ key={item._key}
959
+ style={{
960
+ position: "absolute",
961
+ left: mr.x,
962
+ top: mr.y,
963
+ width: mr.width,
964
+ height: mr.height,
965
+ // Smooth position transitions after swap (disabled during active drag)
966
+ transition:
967
+ isAnyDragActive && !showPlaceholder
968
+ ? undefined
969
+ : "left 200ms ease, top 200ms ease",
970
+ }}
971
+ >
972
+ <ProjectCardWrapper
973
+ item={item}
974
+ thumbMap={thumbMap}
975
+ borderRadius={borderRadius}
976
+ cardWidth={mr.width}
977
+ cardHeight={mr.height}
978
+ isGrabbed={showGrabbed}
979
+ isDragging={showPlaceholder}
980
+ isDropTarget={hoverTargetKey === item._key}
981
+ isSelected={
982
+ isThisBlockSelected &&
983
+ selectedProjectCardKey === item._key
984
+ }
985
+ isAnyDragActive={isAnyDragActive}
986
+ onPointerDown={handleDragStart}
987
+ onSelect={handleSelect}
988
+ />
989
+ </div>
990
+ );
991
+ })}
992
+ </div>
993
+
994
+ {/* Ghost card — portaled to body to escape canvas CSS transform
995
+ (transforms create a new containing block, breaking position:fixed) */}
996
+ {dragState &&
997
+ (dragState.phase === "dragging" || dragState.phase === "cancelling") &&
998
+ draggedItem &&
999
+ createPortal(
1000
+ <GhostCard
1001
+ item={draggedItem}
1002
+ thumbMap={thumbMap}
1003
+ borderRadius={borderRadius}
1004
+ dragState={dragState}
1005
+ />,
1006
+ document.body,
1007
+ )}
1008
+ </div>
1009
+ );
1010
+ }