@morphika/webframe 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +46 -0
- package/admin/assets.ts +4 -0
- package/admin/database.ts +4 -0
- package/admin/index.ts +6 -0
- package/admin/login.ts +4 -0
- package/admin/navigation.ts +4 -0
- package/admin/pages-editor.ts +4 -0
- package/admin/pages.ts +4 -0
- package/admin/projects-editor.ts +4 -0
- package/admin/projects.ts +4 -0
- package/admin/settings.ts +4 -0
- package/admin/setup.ts +4 -0
- package/admin/storage.ts +4 -0
- package/admin/styles.ts +4 -0
- package/app/(site)/[slug]/loading.tsx +20 -0
- package/app/(site)/[slug]/page.tsx +83 -0
- package/app/(site)/error.tsx +32 -0
- package/app/(site)/layout.tsx +53 -0
- package/app/(site)/loading.tsx +20 -0
- package/app/(site)/not-found.tsx +41 -0
- package/app/(site)/page.tsx +43 -0
- package/app/(site)/preview/page.tsx +99 -0
- package/app/(site)/work/[slug]/loading.tsx +23 -0
- package/app/(site)/work/[slug]/page.tsx +84 -0
- package/app/admin/assets/page.tsx +573 -0
- package/app/admin/database/page.tsx +302 -0
- package/app/admin/error.tsx +53 -0
- package/app/admin/layout.tsx +273 -0
- package/app/admin/login/page.tsx +88 -0
- package/app/admin/navigation/page.tsx +157 -0
- package/app/admin/page.tsx +17 -0
- package/app/admin/pages/[slug]/page.tsx +849 -0
- package/app/admin/pages/page.tsx +588 -0
- package/app/admin/projects/[slug]/page.tsx +3 -0
- package/app/admin/projects/page.tsx +669 -0
- package/app/admin/settings/page.tsx +132 -0
- package/app/admin/setup/page.tsx +64 -0
- package/app/admin/storage/page.tsx +518 -0
- package/app/admin/styles/page.tsx +243 -0
- package/app/api/admin/assets/file/route.ts +81 -0
- package/app/api/admin/assets/health/route.ts +170 -0
- package/app/api/admin/assets/register/route.ts +163 -0
- package/app/api/admin/assets/registry/route.ts +98 -0
- package/app/api/admin/assets/relink/confirm/route.ts +242 -0
- package/app/api/admin/assets/relink/route.ts +202 -0
- package/app/api/admin/assets/scan/route.ts +271 -0
- package/app/api/admin/auth/route.ts +160 -0
- package/app/api/admin/custom-sections/[slug]/route.ts +159 -0
- package/app/api/admin/custom-sections/route.ts +127 -0
- package/app/api/admin/database/route.ts +53 -0
- package/app/api/admin/pages/[slug]/duplicate/route.ts +91 -0
- package/app/api/admin/pages/[slug]/route.ts +617 -0
- package/app/api/admin/pages/[slug]/set-home/route.ts +76 -0
- package/app/api/admin/pages/route.ts +129 -0
- package/app/api/admin/preview/route.ts +53 -0
- package/app/api/admin/r2/connect/route.ts +181 -0
- package/app/api/admin/r2/delete/route.ts +198 -0
- package/app/api/admin/r2/disconnect/route.ts +42 -0
- package/app/api/admin/r2/rename/route.ts +265 -0
- package/app/api/admin/r2/status/route.ts +106 -0
- package/app/api/admin/r2/upload-url/route.ts +148 -0
- package/app/api/admin/revalidate/route.ts +55 -0
- package/app/api/admin/settings/route.ts +279 -0
- package/app/api/admin/setup/complete/route.ts +51 -0
- package/app/api/admin/setup/route.ts +118 -0
- package/app/api/admin/storage/switch/route.ts +117 -0
- package/app/api/admin/styles/fonts/route.ts +97 -0
- package/app/api/admin/styles/route.ts +304 -0
- package/app/api/assets/[...path]/route.ts +98 -0
- package/app/api/custom-sections/[id]/route.ts +43 -0
- package/app/api/draft-mode/disable/route.ts +10 -0
- package/app/api/draft-mode/enable/route.ts +26 -0
- package/app/api/projects/route.ts +42 -0
- package/app/api/styles/route.ts +88 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +7 -0
- package/app/layout.tsx +53 -0
- package/app/robots.ts +17 -0
- package/app/sitemap.ts +48 -0
- package/app/studio/[[...index]]/page.tsx +8 -0
- package/components/admin/MetadataEditor.tsx +173 -0
- package/components/admin/PublishToggle.tsx +130 -0
- package/components/admin/icons.tsx +40 -0
- package/components/admin/nav-builder/NavBuilder.tsx +182 -0
- package/components/admin/nav-builder/NavBuilderGrid.tsx +326 -0
- package/components/admin/nav-builder/NavGeneralSettings.tsx +275 -0
- package/components/admin/nav-builder/NavGridCell.tsx +48 -0
- package/components/admin/nav-builder/NavGridItem.tsx +189 -0
- package/components/admin/nav-builder/NavItemSettings.tsx +288 -0
- package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -0
- package/components/admin/nav-builder/NavLivePreview.tsx +125 -0
- package/components/admin/nav-builder/NavSettingsFields.tsx +248 -0
- package/components/admin/nav-builder/NavSettingsPanel.tsx +127 -0
- package/components/admin/nav-builder/index.ts +10 -0
- package/components/admin/nav-builder/nav-builder-utils.ts +238 -0
- package/components/admin/setup-wizard/BrandingStep.tsx +218 -0
- package/components/admin/setup-wizard/DatabaseStep.tsx +331 -0
- package/components/admin/setup-wizard/DoneStep.tsx +187 -0
- package/components/admin/setup-wizard/SetupWizard.tsx +166 -0
- package/components/admin/setup-wizard/StorageStep.tsx +308 -0
- package/components/admin/setup-wizard/WelcomeStep.tsx +96 -0
- package/components/admin/setup-wizard/index.ts +9 -0
- package/components/admin/styles/ColorsEditor.tsx +214 -0
- package/components/admin/styles/FontsEditor.tsx +258 -0
- package/components/admin/styles/GridLayoutEditor.tsx +292 -0
- package/components/admin/styles/LinksButtonsEditor.tsx +120 -0
- package/components/admin/styles/TypographyEditor.tsx +266 -0
- package/components/admin/styles/index.ts +9 -0
- package/components/admin/styles/shared.tsx +68 -0
- package/components/blocks/BlockRenderer.tsx +404 -0
- package/components/blocks/ButtonBlockRenderer.tsx +52 -0
- package/components/blocks/CoverBlockRenderer.tsx +239 -0
- package/components/blocks/CustomSectionInstanceRenderer.tsx +82 -0
- package/components/blocks/EnterAnimationWrapper.tsx +140 -0
- package/components/blocks/HoverAnimationWrapper.tsx +308 -0
- package/components/blocks/ImageBlockRenderer.tsx +61 -0
- package/components/blocks/ImageGridBlockRenderer.tsx +545 -0
- package/components/blocks/PageBackground.tsx +28 -0
- package/components/blocks/PageNavAnimation.tsx +35 -0
- package/components/blocks/PageNavColor.tsx +24 -0
- package/components/blocks/PageRenderer.tsx +142 -0
- package/components/blocks/ParallaxGroupRenderer.tsx +448 -0
- package/components/blocks/ParallaxSlideRenderer.tsx +175 -0
- package/components/blocks/ProjectGridBlockRenderer.tsx +556 -0
- package/components/blocks/SectionRenderer.tsx +170 -0
- package/components/blocks/SectionV2Renderer.tsx +330 -0
- package/components/blocks/ShaderCanvas.tsx +392 -0
- package/components/blocks/SpacerBlockRenderer.tsx +17 -0
- package/components/blocks/TextBlockRenderer.tsx +87 -0
- package/components/blocks/TypewriterRichText.tsx +464 -0
- package/components/blocks/TypewriterWrapper.tsx +149 -0
- package/components/blocks/VideoBlockRenderer.tsx +304 -0
- package/components/blocks/index.ts +2 -0
- package/components/builder/AssetBrowser.tsx +2 -0
- package/components/builder/BlockLivePreview.tsx +101 -0
- package/components/builder/BlockTypePicker.tsx +178 -0
- package/components/builder/BuilderCanvas.tsx +354 -0
- package/components/builder/CanvasMinimap.tsx +200 -0
- package/components/builder/CanvasToolbar.tsx +202 -0
- package/components/builder/ColorPicker.tsx +243 -0
- package/components/builder/ColorSwatchPicker.tsx +274 -0
- package/components/builder/ColumnDragContext.tsx +51 -0
- package/components/builder/ColumnDragOverlay.tsx +110 -0
- package/components/builder/CustomSectionInstanceCard.tsx +97 -0
- package/components/builder/DeviceFrame.tsx +123 -0
- package/components/builder/DndWrapper.tsx +337 -0
- package/components/builder/InsertionLines.tsx +186 -0
- package/components/builder/ParallaxGroupCanvas.tsx +228 -0
- package/components/builder/ParallaxSlideHeader.tsx +113 -0
- package/components/builder/ReadOnlyFrame.tsx +417 -0
- package/components/builder/SectionEditorBar.tsx +288 -0
- package/components/builder/SectionTypePicker.tsx +422 -0
- package/components/builder/SectionV2Canvas.tsx +297 -0
- package/components/builder/SectionV2Column.tsx +488 -0
- package/components/builder/SettingsPanel.tsx +911 -0
- package/components/builder/SortableBlock.tsx +230 -0
- package/components/builder/SortableRow.tsx +362 -0
- package/components/builder/VirtualAssetGrid.tsx +397 -0
- package/components/builder/asset-browser/AssetBrowser.tsx +178 -0
- package/components/builder/asset-browser/FileLightbox.tsx +116 -0
- package/components/builder/asset-browser/FolderTreeItem.tsx +55 -0
- package/components/builder/asset-browser/R2BrowserContent.tsx +436 -0
- package/components/builder/asset-browser/R2ContextMenu.tsx +98 -0
- package/components/builder/asset-browser/VideoThumbnail.tsx +63 -0
- package/components/builder/asset-browser/helpers.ts +88 -0
- package/components/builder/asset-browser/index.ts +1 -0
- package/components/builder/asset-browser/types.ts +49 -0
- package/components/builder/asset-browser/useAssetBrowser.ts +344 -0
- package/components/builder/asset-browser/useR2DragDrop.ts +116 -0
- package/components/builder/asset-browser/useR2Operations.ts +189 -0
- package/components/builder/blockStyles.tsx +295 -0
- package/components/builder/editors/ButtonBlockEditor.tsx +184 -0
- package/components/builder/editors/CoverBlockEditor.tsx +488 -0
- package/components/builder/editors/EnterAnimationPicker.tsx +297 -0
- package/components/builder/editors/HoverEffectPicker.tsx +209 -0
- package/components/builder/editors/ImageBlockEditor.tsx +206 -0
- package/components/builder/editors/ImageGridBlockEditor.tsx +386 -0
- package/components/builder/editors/ProjectGridEditor.tsx +648 -0
- package/components/builder/editors/SpacerBlockEditor.tsx +167 -0
- package/components/builder/editors/StaggerSettings.tsx +108 -0
- package/components/builder/editors/TextAlignmentIcons.tsx +39 -0
- package/components/builder/editors/TextBlockEditor.tsx +462 -0
- package/components/builder/editors/TextStylePicker.tsx +183 -0
- package/components/builder/editors/VideoBlockEditor.tsx +278 -0
- package/components/builder/editors/index.ts +10 -0
- package/components/builder/editors/shared.tsx +345 -0
- package/components/builder/hooks/useColumnDrag.ts +472 -0
- package/components/builder/hooks/useColumnResize.ts +221 -0
- package/components/builder/index.ts +12 -0
- package/components/builder/live-preview/LiveButtonPreview.tsx +38 -0
- package/components/builder/live-preview/LiveCoverPreview.tsx +146 -0
- package/components/builder/live-preview/LiveImageGridPreview.tsx +123 -0
- package/components/builder/live-preview/LiveImagePreview.tsx +107 -0
- package/components/builder/live-preview/LiveProjectGridPreview.tsx +1010 -0
- package/components/builder/live-preview/LiveSpacerPreview.tsx +9 -0
- package/components/builder/live-preview/LiveTextEditor.tsx +198 -0
- package/components/builder/live-preview/LiveVideoPreview.tsx +98 -0
- package/components/builder/live-preview/index.ts +10 -0
- package/components/builder/live-preview/shared.tsx +153 -0
- package/components/builder/settings-panel/BlockLayoutTab.tsx +532 -0
- package/components/builder/settings-panel/BlockSettings.tsx +94 -0
- package/components/builder/settings-panel/ColumnV2Settings.tsx +160 -0
- package/components/builder/settings-panel/LayoutTab.tsx +310 -0
- package/components/builder/settings-panel/PageSettings.tsx +200 -0
- package/components/builder/settings-panel/ParallaxGroupSettings.tsx +118 -0
- package/components/builder/settings-panel/ParallaxSlideSettings.tsx +178 -0
- package/components/builder/settings-panel/SectionV2AnimationTab.tsx +103 -0
- package/components/builder/settings-panel/SectionV2LayoutTab.tsx +312 -0
- package/components/builder/settings-panel/SectionV2Settings.tsx +323 -0
- package/components/builder/settings-panel/TRBLInputs.tsx +51 -0
- package/components/builder/settings-panel/index.ts +19 -0
- package/components/builder/settings-panel/responsive-helpers.ts +524 -0
- package/components/ui/CustomCursor.tsx +118 -0
- package/components/ui/NavContentLightbox.tsx +152 -0
- package/components/ui/Navbar.tsx +582 -0
- package/components/ui/PortfolioTracker.tsx +87 -0
- package/components/ui/ScrollToTop.tsx +47 -0
- package/lib/animation/enter-presets.ts +147 -0
- package/lib/animation/enter-resolve.ts +90 -0
- package/lib/animation/enter-types.ts +128 -0
- package/lib/animation/hover-effect-presets.ts +210 -0
- package/lib/animation/hover-effect-types.ts +126 -0
- package/lib/asset-retry.ts +111 -0
- package/lib/assets.ts +92 -0
- package/lib/audit.ts +35 -0
- package/lib/auth-token.ts +94 -0
- package/lib/auth.ts +13 -0
- package/lib/builder/cascade-helpers.ts +51 -0
- package/lib/builder/cascade.ts +533 -0
- package/lib/builder/constants.ts +103 -0
- package/lib/builder/defaults.ts +182 -0
- package/lib/builder/history.ts +48 -0
- package/lib/builder/index.ts +21 -0
- package/lib/builder/layout-styles.ts +344 -0
- package/lib/builder/masonry.ts +166 -0
- package/lib/builder/responsive.ts +156 -0
- package/lib/builder/serializer.ts +845 -0
- package/lib/builder/store-blocks.ts +193 -0
- package/lib/builder/store-canvas.ts +319 -0
- package/lib/builder/store-helpers.ts +490 -0
- package/lib/builder/store-sections.ts +709 -0
- package/lib/builder/store.ts +333 -0
- package/lib/builder/templates.ts +297 -0
- package/lib/builder/types.ts +374 -0
- package/lib/builder/utils.ts +37 -0
- package/lib/color-utils.ts +116 -0
- package/lib/config/index.ts +57 -0
- package/lib/config/types.ts +122 -0
- package/lib/contexts/AssetContext.tsx +79 -0
- package/lib/contexts/NavAnimationContext.tsx +44 -0
- package/lib/contexts/NavColorContext.tsx +38 -0
- package/lib/contexts/PageExitContext.tsx +194 -0
- package/lib/contexts/ThumbStatusContext.tsx +83 -0
- package/lib/csrf-client.ts +34 -0
- package/lib/csrf.ts +68 -0
- package/lib/format-utils.ts +24 -0
- package/lib/hooks/useViewport.ts +42 -0
- package/lib/logger.ts +81 -0
- package/lib/revalidate.ts +23 -0
- package/lib/sanitize.ts +91 -0
- package/lib/sanity/client.ts +8 -0
- package/lib/sanity/queries.ts +486 -0
- package/lib/sanity/types.ts +869 -0
- package/lib/sanity/writeClient.ts +24 -0
- package/lib/security.ts +402 -0
- package/lib/setup/detect.ts +156 -0
- package/lib/shader/glsl/index.ts +27 -0
- package/lib/shader/glsl/pixelate.ts +51 -0
- package/lib/shader/glsl/rgb-shift.ts +45 -0
- package/lib/shader/glsl/ripple.ts +46 -0
- package/lib/shader/glsl/vertex.ts +14 -0
- package/lib/storage/index.ts +211 -0
- package/lib/storage/r2-adapter.ts +286 -0
- package/lib/storage/types.ts +125 -0
- package/lib/styles/provider.tsx +267 -0
- package/lib/thumbnails/generate.ts +151 -0
- package/lib/utils.ts +6 -0
- package/package.json +212 -0
- package/sanity/compose.ts +65 -0
- package/sanity/sanity.config.ts +126 -0
- package/sanity/schemas/assetRegistry.ts +301 -0
- package/sanity/schemas/blocks/blockLayout.ts +90 -0
- package/sanity/schemas/blocks/buttonBlock.ts +82 -0
- package/sanity/schemas/blocks/coverBlock.ts +229 -0
- package/sanity/schemas/blocks/imageBlock.ts +58 -0
- package/sanity/schemas/blocks/imageGridBlock.ts +112 -0
- package/sanity/schemas/blocks/index.ts +9 -0
- package/sanity/schemas/blocks/projectGridBlock.ts +251 -0
- package/sanity/schemas/blocks/spacerBlock.ts +41 -0
- package/sanity/schemas/blocks/textBlock.ts +139 -0
- package/sanity/schemas/blocks/videoBlock.ts +80 -0
- package/sanity/schemas/customSection.ts +69 -0
- package/sanity/schemas/customSectionInstance.ts +163 -0
- package/sanity/schemas/index.ts +111 -0
- package/sanity/schemas/objects/enterAnimationConfig.ts +72 -0
- package/sanity/schemas/objects/hoverEffectConfig.ts +90 -0
- package/sanity/schemas/objects/parallaxGroup.ts +66 -0
- package/sanity/schemas/objects/parallaxSlide.ts +217 -0
- package/sanity/schemas/objects/typewriterConfig.ts +38 -0
- package/sanity/schemas/page.ts +162 -0
- package/sanity/schemas/pageSection.ts +157 -0
- package/sanity/schemas/pageSectionV2.ts +269 -0
- package/sanity/schemas/siteSettings.ts +256 -0
- package/sanity/schemas/siteStyles.ts +210 -0
- package/site/error.ts +4 -0
- package/site/index.ts +8 -0
- package/site/not-found.ts +4 -0
- package/site/page.ts +4 -0
- package/site/preview.ts +4 -0
- package/site/robots.ts +4 -0
- package/site/sitemap.ts +4 -0
- package/site/work.ts +4 -0
- package/studio/index.ts +4 -0
- package/styles/admin.css +85 -0
- package/styles/animations.css +237 -0
- package/styles/base.css +148 -0
- package/styles/globals.css +10 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,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
|
+
}
|