@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,845 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bidirectional serializer: Builder state ↔ Sanity document.
|
|
3
|
+
*
|
|
4
|
+
* - `documentToState(doc)` — Sanity document → Zustand state shape
|
|
5
|
+
* - `stateToDocument(state)` — Zustand state → Sanity-compatible document
|
|
6
|
+
*
|
|
7
|
+
* Handles _key generation for objects that lack keys,
|
|
8
|
+
* normalizes missing fields, and strips undefined values.
|
|
9
|
+
*
|
|
10
|
+
* Session 76: Added PageSection support + auto-migration from old matryoshka format.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Page, ContentBlock, ContentItem, PageSection, PageSectionV2, SectionBlock, SectionColumn, SectionV2Settings, CustomSectionInstance, ParallaxGroup } from "../../lib/sanity/types";
|
|
14
|
+
import { isPageSection, isPageSectionV2, isCustomSectionInstance, isParallaxGroup } from "../../lib/sanity/types";
|
|
15
|
+
import type { BuilderState, PageSettings } from "./types";
|
|
16
|
+
import { isSectionBlockType } from "./types";
|
|
17
|
+
import { generateKey } from "./utils";
|
|
18
|
+
import { DEFAULT_BG_COLOR, DEFAULT_TEXT_COLOR, DEFAULT_GRID_WIDTH } from "./constants";
|
|
19
|
+
import type { EnterAnimationConfig } from "../../lib/animation/enter-types";
|
|
20
|
+
|
|
21
|
+
// ============================================
|
|
22
|
+
// Project Grid v1 → v2 Migration (Session 105)
|
|
23
|
+
// ============================================
|
|
24
|
+
|
|
25
|
+
/** Map v1 string gap values to pixel numbers */
|
|
26
|
+
const GAP_V1_MAP: Record<string, number> = {
|
|
27
|
+
small: 8,
|
|
28
|
+
medium: 16,
|
|
29
|
+
large: 32,
|
|
30
|
+
xlarge: 48,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Detect and migrate a v1 projectGridBlock to v2 format.
|
|
35
|
+
* v1 is identified by the presence of `grid_layout` (string field).
|
|
36
|
+
* v2 is identified by the presence of `columns` (number field).
|
|
37
|
+
* If already v2 or not a projectGridBlock, returns as-is.
|
|
38
|
+
*/
|
|
39
|
+
function migrateProjectGridV1ToV2(block: Record<string, unknown>): void {
|
|
40
|
+
if (block._type !== "projectGridBlock") return;
|
|
41
|
+
|
|
42
|
+
// Already v2: has `columns` as a number
|
|
43
|
+
if (typeof block.columns === "number") return;
|
|
44
|
+
|
|
45
|
+
// Not v1 either (fresh block without grid_layout) — apply defaults
|
|
46
|
+
if (!block.grid_layout && typeof block.columns !== "number") {
|
|
47
|
+
block.columns = 3;
|
|
48
|
+
block.aspect_ratios = ["16/9"];
|
|
49
|
+
block.gap_v = 16;
|
|
50
|
+
block.gap_h = 16;
|
|
51
|
+
block.hover_effect = block.hover_effect || "scale";
|
|
52
|
+
block.show_subtitle = block.show_subtitle ?? true;
|
|
53
|
+
block.border_radius = 0;
|
|
54
|
+
block.video_mode = "off";
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ─── v1 → v2 migration ───
|
|
59
|
+
|
|
60
|
+
// columns: columns_desktop → columns (clamp 1–6)
|
|
61
|
+
const colDesktop = typeof block.columns_desktop === "number" ? block.columns_desktop : 2;
|
|
62
|
+
block.columns = Math.max(1, Math.min(6, colDesktop));
|
|
63
|
+
|
|
64
|
+
// gap: string → gap_v + gap_h (both same value)
|
|
65
|
+
const gapStr = typeof block.gap === "string" ? block.gap : "large";
|
|
66
|
+
const gapPx = GAP_V1_MAP[gapStr] ?? 32;
|
|
67
|
+
block.gap_v = gapPx;
|
|
68
|
+
block.gap_h = gapPx;
|
|
69
|
+
|
|
70
|
+
// card_aspect_ratio → aspect_ratios
|
|
71
|
+
const oldRatio = typeof block.card_aspect_ratio === "string" ? block.card_aspect_ratio : "16/9";
|
|
72
|
+
if (oldRatio === "random") {
|
|
73
|
+
block.aspect_ratios = ["16/9", "1/1", "9/16"];
|
|
74
|
+
} else {
|
|
75
|
+
block.aspect_ratios = [oldRatio];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// hover_effect: "overlay" → "scale", rest 1:1
|
|
79
|
+
const oldHover = typeof block.hover_effect === "string" ? block.hover_effect : "overlay";
|
|
80
|
+
if (oldHover === "overlay") {
|
|
81
|
+
block.hover_effect = "scale";
|
|
82
|
+
}
|
|
83
|
+
// "scale" and "none" pass through
|
|
84
|
+
|
|
85
|
+
// border_radius: string → number
|
|
86
|
+
const oldRadius = block.card_border_radius;
|
|
87
|
+
block.border_radius = typeof oldRadius === "string" ? (parseInt(oldRadius, 10) || 0) : 0;
|
|
88
|
+
|
|
89
|
+
// video: video_autoloop + video_hover → video_mode
|
|
90
|
+
if (block.video_autoloop === true) {
|
|
91
|
+
block.video_mode = "autoloop";
|
|
92
|
+
} else if (block.video_hover === true) {
|
|
93
|
+
block.video_mode = "hover";
|
|
94
|
+
} else {
|
|
95
|
+
block.video_mode = "off";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// show_subtitle: direct copy (default true)
|
|
99
|
+
block.show_subtitle = block.show_subtitle ?? true;
|
|
100
|
+
|
|
101
|
+
// ─── Clean up v1 fields (not needed in builder state) ───
|
|
102
|
+
delete block.grid_layout;
|
|
103
|
+
delete block.gap;
|
|
104
|
+
delete block.auto_columns;
|
|
105
|
+
delete block.card_size;
|
|
106
|
+
delete block.columns_desktop;
|
|
107
|
+
delete block.card_aspect_ratio;
|
|
108
|
+
delete block.card_border_radius;
|
|
109
|
+
delete block.video_hover;
|
|
110
|
+
delete block.video_autoloop;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Normalize animation fields on a block during read.
|
|
115
|
+
* Post-migration (Session 122): only new fields exist in Sanity.
|
|
116
|
+
* This is a no-op now but kept as a hook point for future needs.
|
|
117
|
+
*/
|
|
118
|
+
function normalizeBlockAnimationFields(_block: Record<string, unknown>): void {
|
|
119
|
+
// No-op: migration completed in Session 122.
|
|
120
|
+
// All documents now use enter_animation / hover_effect directly.
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ============================================
|
|
124
|
+
// Constants
|
|
125
|
+
// ============================================
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Map block types to section_type values.
|
|
129
|
+
* Used during migration and validation to ensure consistent section type naming.
|
|
130
|
+
*/
|
|
131
|
+
const SECTION_TYPE_MAP: Record<string, string> = {
|
|
132
|
+
projectGridBlock: "projectGrid",
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
// ============================================
|
|
136
|
+
// Sanity Document → Builder State
|
|
137
|
+
// ============================================
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Ensure every item has a _key.
|
|
141
|
+
* Sanity sometimes omits _key on newly created objects.
|
|
142
|
+
*/
|
|
143
|
+
function ensureKeys<T extends { _key?: string }>(items: T[] | undefined): (T & { _key: string })[] {
|
|
144
|
+
if (!items) return [];
|
|
145
|
+
return items.map((item) => ({
|
|
146
|
+
...item,
|
|
147
|
+
_key: item._key || generateKey(),
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Normalize block layout responsive overrides.
|
|
154
|
+
* Cleans up empty/invalid values within responsive.tablet.layout and responsive.phone.layout.
|
|
155
|
+
* Strips empty viewport objects and empty layout sub-objects.
|
|
156
|
+
*/
|
|
157
|
+
/**
|
|
158
|
+
* Clean a layout object by removing empty, null, and empty-string values.
|
|
159
|
+
* Returns undefined if the cleaned object has no keys.
|
|
160
|
+
*/
|
|
161
|
+
function cleanLayoutObject(obj: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
162
|
+
const cleaned: Record<string, unknown> = {};
|
|
163
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
164
|
+
if (val !== undefined && val !== null && val !== "") {
|
|
165
|
+
cleaned[key] = val;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return Object.keys(cleaned).length > 0 ? cleaned : undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Normalize block layout responsive overrides.
|
|
173
|
+
* Cleans up empty/invalid values within responsive.tablet.layout and responsive.phone.layout.
|
|
174
|
+
* Strips empty viewport objects and empty layout sub-objects.
|
|
175
|
+
*/
|
|
176
|
+
function normalizeBlockResponsive(
|
|
177
|
+
responsive: Record<string, Record<string, unknown>> | undefined
|
|
178
|
+
): Record<string, Record<string, unknown>> | undefined {
|
|
179
|
+
if (!responsive) return undefined;
|
|
180
|
+
|
|
181
|
+
const result: Record<string, Record<string, unknown>> = {};
|
|
182
|
+
|
|
183
|
+
for (const vp of ["tablet", "phone"] as const) {
|
|
184
|
+
const vpOverrides = responsive[vp];
|
|
185
|
+
if (!vpOverrides || typeof vpOverrides !== "object") continue;
|
|
186
|
+
|
|
187
|
+
const cleaned: Record<string, unknown> = {};
|
|
188
|
+
|
|
189
|
+
for (const [key, val] of Object.entries(vpOverrides)) {
|
|
190
|
+
if (val === undefined || val === null) continue;
|
|
191
|
+
|
|
192
|
+
if (key === "layout" && typeof val === "object" && val !== null) {
|
|
193
|
+
const layoutCleaned = cleanLayoutObject(val as Record<string, unknown>);
|
|
194
|
+
if (layoutCleaned) cleaned.layout = layoutCleaned;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Other block properties (non-layout)
|
|
199
|
+
if (typeof val === "string" && val.trim() === "") continue;
|
|
200
|
+
cleaned[key] = val;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (Object.keys(cleaned).length > 0) {
|
|
204
|
+
result[vp] = cleaned;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
function normalizePageSection(section: Partial<PageSection> & { _key: string }): PageSection {
|
|
213
|
+
const ensuredBlock = ensureKeys((section.block || []) as SectionBlock[]) as (SectionBlock & { _key: string })[];
|
|
214
|
+
const ensuredBlockType = ensuredBlock[0]?._type || "projectGridBlock";
|
|
215
|
+
// BUG-022 fix: Preserve the original section_type even if unknown.
|
|
216
|
+
// Only default when the field is missing entirely (new sections).
|
|
217
|
+
const sectionType = section.section_type || (SECTION_TYPE_MAP[ensuredBlockType] || "projectGrid");
|
|
218
|
+
|
|
219
|
+
// Warn on unknown types but don't override — future types should survive round-trips
|
|
220
|
+
if (!Object.values(SECTION_TYPE_MAP).includes(sectionType)) {
|
|
221
|
+
console.warn(`[Serializer] Unknown section_type: ${sectionType} — preserving as-is`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Auto-migrate projectGridBlock v1 → v2
|
|
225
|
+
if (ensuredBlock[0]) {
|
|
226
|
+
migrateProjectGridV1ToV2(ensuredBlock[0] as unknown as Record<string, unknown>);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Session 117: Migrate animation fields on section blocks
|
|
230
|
+
if (ensuredBlock[0]) {
|
|
231
|
+
normalizeBlockAnimationFields(ensuredBlock[0] as unknown as Record<string, unknown>);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
_type: "pageSection",
|
|
236
|
+
_key: section._key,
|
|
237
|
+
section_type: sectionType as import("@/lib/sanity/types").PageSectionType,
|
|
238
|
+
block: [ensuredBlock[0] || { _type: ensuredBlockType, _key: generateKey() }] as [SectionBlock],
|
|
239
|
+
settings: section.settings || {},
|
|
240
|
+
// BUG-013 fix: preserve responsive overrides for sections
|
|
241
|
+
...(section.responsive ? { responsive: section.responsive } : {}),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Normalize a PageSectionV2 from Sanity data into the builder's shape.
|
|
247
|
+
* Ensures all columns have _keys, validates grid positions, and fills defaults.
|
|
248
|
+
*/
|
|
249
|
+
function normalizePageSectionV2(section: Partial<PageSectionV2> & { _key: string }): PageSectionV2 {
|
|
250
|
+
const rawColumns = ensureKeys((section.columns || []) as Array<Partial<SectionColumn> & { _key?: string }>);
|
|
251
|
+
|
|
252
|
+
const columns: SectionColumn[] = rawColumns.map((col) => {
|
|
253
|
+
const colRecord = col as unknown as Record<string, unknown>;
|
|
254
|
+
// Migrate column-level enter_animation (new field, no old equivalent for columns)
|
|
255
|
+
const colEnterAnimation = colRecord.enter_animation as EnterAnimationConfig | undefined;
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
_key: col._key || generateKey(),
|
|
259
|
+
grid_column: col.grid_column || 1,
|
|
260
|
+
grid_row: col.grid_row || 1,
|
|
261
|
+
span: col.span || 12,
|
|
262
|
+
blocks: ensureKeys(col.blocks || []).map((b) => {
|
|
263
|
+
const blockRecord = b as unknown as Record<string, unknown>;
|
|
264
|
+
|
|
265
|
+
// Migrate animation fields on blocks (Session 117)
|
|
266
|
+
normalizeBlockAnimationFields(blockRecord);
|
|
267
|
+
|
|
268
|
+
const blockResponsive = normalizeBlockResponsive(
|
|
269
|
+
blockRecord.responsive as Record<string, Record<string, unknown>> | undefined
|
|
270
|
+
);
|
|
271
|
+
if (blockResponsive) {
|
|
272
|
+
return { ...b, responsive: blockResponsive } as ContentBlock;
|
|
273
|
+
}
|
|
274
|
+
if (blockRecord.responsive && !blockResponsive) {
|
|
275
|
+
const { responsive: _, ...rest } = blockRecord;
|
|
276
|
+
return rest as unknown as ContentBlock;
|
|
277
|
+
}
|
|
278
|
+
return b as ContentBlock;
|
|
279
|
+
}),
|
|
280
|
+
...(colEnterAnimation ? { enter_animation: colEnterAnimation } : {}),
|
|
281
|
+
};
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const rawSettings = section.settings || {} as Partial<SectionV2Settings>;
|
|
285
|
+
|
|
286
|
+
return {
|
|
287
|
+
_type: "pageSectionV2",
|
|
288
|
+
_key: section._key,
|
|
289
|
+
section_type: "empty-v2",
|
|
290
|
+
columns,
|
|
291
|
+
settings: {
|
|
292
|
+
preset: rawSettings.preset || "full",
|
|
293
|
+
grid_columns: rawSettings.grid_columns || 12,
|
|
294
|
+
col_gap: rawSettings.col_gap ?? 20,
|
|
295
|
+
row_gap: rawSettings.row_gap ?? 20,
|
|
296
|
+
spacing_top: rawSettings.spacing_top,
|
|
297
|
+
spacing_right: rawSettings.spacing_right,
|
|
298
|
+
spacing_bottom: rawSettings.spacing_bottom,
|
|
299
|
+
spacing_left: rawSettings.spacing_left,
|
|
300
|
+
offset_top: rawSettings.offset_top,
|
|
301
|
+
offset_right: rawSettings.offset_right,
|
|
302
|
+
offset_bottom: rawSettings.offset_bottom,
|
|
303
|
+
offset_left: rawSettings.offset_left,
|
|
304
|
+
background_color: rawSettings.background_color,
|
|
305
|
+
background_opacity: rawSettings.background_opacity,
|
|
306
|
+
background_image: rawSettings.background_image,
|
|
307
|
+
background_size: rawSettings.background_size,
|
|
308
|
+
background_position: rawSettings.background_position,
|
|
309
|
+
background_repeat: rawSettings.background_repeat,
|
|
310
|
+
border_color: rawSettings.border_color,
|
|
311
|
+
border_width: rawSettings.border_width,
|
|
312
|
+
border_style: rawSettings.border_style,
|
|
313
|
+
border_sides: rawSettings.border_sides,
|
|
314
|
+
border_radius: rawSettings.border_radius,
|
|
315
|
+
enter_animation: rawSettings.enter_animation,
|
|
316
|
+
stagger: rawSettings.stagger,
|
|
317
|
+
},
|
|
318
|
+
...(section.responsive ? { responsive: section.responsive } : {}),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ============================================
|
|
323
|
+
// ParallaxGroup normalization (Session 123)
|
|
324
|
+
// ============================================
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Normalize a ParallaxGroup from Sanity data into the builder's shape.
|
|
328
|
+
* Each slide contains V2 section data (columns + settings).
|
|
329
|
+
*/
|
|
330
|
+
function normalizeParallaxGroup(group: Partial<ParallaxGroup> & { _key: string }): ParallaxGroup {
|
|
331
|
+
const rawSlides = ensureKeys(
|
|
332
|
+
(group.slides || []) as Array<Partial<import("@/lib/sanity/types").ParallaxSlideV2> & { _key?: string }>
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const slides: import("@/lib/sanity/types").ParallaxSlideV2[] = rawSlides.map((slide) => {
|
|
336
|
+
// Normalize the V2 columns within each slide (reuse existing logic)
|
|
337
|
+
const slideKey = slide._key || generateKey();
|
|
338
|
+
const tempV2: Partial<PageSectionV2> & { _key: string } = {
|
|
339
|
+
_key: slideKey,
|
|
340
|
+
columns: slide.columns as SectionColumn[] | undefined,
|
|
341
|
+
settings: slide.section_settings as SectionV2Settings | undefined,
|
|
342
|
+
};
|
|
343
|
+
const normalizedV2 = normalizePageSectionV2(tempV2);
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
_key: slideKey,
|
|
347
|
+
_type: "parallaxSlide" as const,
|
|
348
|
+
background_type: slide.background_type || "image",
|
|
349
|
+
background_image: slide.background_image,
|
|
350
|
+
background_video: slide.background_video,
|
|
351
|
+
background_position: slide.background_position || "center center",
|
|
352
|
+
background_overlay_color: slide.background_overlay_color || "#000000",
|
|
353
|
+
background_overlay_opacity: slide.background_overlay_opacity ?? 0,
|
|
354
|
+
nav_color: slide.nav_color,
|
|
355
|
+
columns: normalizedV2.columns,
|
|
356
|
+
section_settings: normalizedV2.settings,
|
|
357
|
+
};
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
_type: "parallaxGroup",
|
|
362
|
+
_key: group._key,
|
|
363
|
+
slides,
|
|
364
|
+
transition_effect: group.transition_effect || "parallax",
|
|
365
|
+
snap_enabled: group.snap_enabled ?? true,
|
|
366
|
+
parallax_intensity: group.parallax_intensity ?? 0.4,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Serialize a ParallaxGroup for Sanity.
|
|
372
|
+
*/
|
|
373
|
+
function serializeParallaxGroup(group: ParallaxGroup): Record<string, unknown> {
|
|
374
|
+
return {
|
|
375
|
+
_key: group._key,
|
|
376
|
+
_type: "parallaxGroup",
|
|
377
|
+
transition_effect: group.transition_effect,
|
|
378
|
+
snap_enabled: group.snap_enabled,
|
|
379
|
+
parallax_intensity: group.parallax_intensity,
|
|
380
|
+
slides: group.slides.map((slide) => {
|
|
381
|
+
// Serialize the V2 section data within each slide
|
|
382
|
+
const tempV2: PageSectionV2 = {
|
|
383
|
+
_type: "pageSectionV2",
|
|
384
|
+
_key: slide._key,
|
|
385
|
+
section_type: "empty-v2",
|
|
386
|
+
columns: slide.columns,
|
|
387
|
+
settings: slide.section_settings,
|
|
388
|
+
};
|
|
389
|
+
const serializedV2 = serializePageSectionV2(tempV2);
|
|
390
|
+
|
|
391
|
+
return stripUndefined({
|
|
392
|
+
_key: slide._key,
|
|
393
|
+
_type: "parallaxSlide",
|
|
394
|
+
background_type: slide.background_type,
|
|
395
|
+
background_image: slide.background_image,
|
|
396
|
+
background_video: slide.background_video,
|
|
397
|
+
background_position: slide.background_position,
|
|
398
|
+
background_overlay_color: slide.background_overlay_color,
|
|
399
|
+
background_overlay_opacity: slide.background_overlay_opacity,
|
|
400
|
+
nav_color: slide.nav_color,
|
|
401
|
+
columns: serializedV2.columns,
|
|
402
|
+
section_settings: serializedV2.settings,
|
|
403
|
+
});
|
|
404
|
+
}),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Normalize a content item (PageSection, PageSectionV2, ParallaxGroup, etc.).
|
|
410
|
+
*/
|
|
411
|
+
function migrateContentItem(item: Record<string, unknown>): ContentItem {
|
|
412
|
+
// PageSection — normalize and return
|
|
413
|
+
if (item._type === "pageSection") {
|
|
414
|
+
return normalizePageSection(item as unknown as Partial<PageSection> & { _key: string });
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// PageSectionV2 — normalize and return
|
|
418
|
+
if (item._type === "pageSectionV2") {
|
|
419
|
+
return normalizePageSectionV2(item as unknown as Partial<PageSectionV2> & { _key: string });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// CustomSectionInstance — pass through reference + per-instance overrides (Session 107, 130)
|
|
423
|
+
if (item._type === "customSectionInstance") {
|
|
424
|
+
const raw = item as unknown as Record<string, unknown>;
|
|
425
|
+
return {
|
|
426
|
+
_type: "customSectionInstance",
|
|
427
|
+
_key: (item._key as string) || generateKey(),
|
|
428
|
+
custom_section_id: (item.custom_section_id as string) || "",
|
|
429
|
+
custom_section_slug: (item.custom_section_slug as string) || "",
|
|
430
|
+
custom_section_title: (item.custom_section_title as string) || "Untitled",
|
|
431
|
+
...(raw.settings_overrides && typeof raw.settings_overrides === "object"
|
|
432
|
+
? { settings_overrides: raw.settings_overrides as Partial<SectionV2Settings> }
|
|
433
|
+
: {}),
|
|
434
|
+
...(raw.responsive_overrides && typeof raw.responsive_overrides === "object"
|
|
435
|
+
? { responsive_overrides: raw.responsive_overrides as PageSectionV2["responsive"] }
|
|
436
|
+
: {}),
|
|
437
|
+
} as CustomSectionInstance;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ParallaxGroup — normalize slides (Session 123)
|
|
441
|
+
if (item._type === "parallaxGroup") {
|
|
442
|
+
return normalizeParallaxGroup(item as unknown as Partial<ParallaxGroup> & { _key: string });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Unknown type — warn and skip
|
|
446
|
+
console.warn(`[Serializer] Unknown content item type: ${item._type}, skipping`);
|
|
447
|
+
// Return a dummy PageSectionV2 as fallback
|
|
448
|
+
return normalizePageSectionV2({ _key: generateKey() } as unknown as Partial<PageSectionV2> & { _key: string });
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Convert a Sanity `page` document into the builder's state shape.
|
|
453
|
+
*/
|
|
454
|
+
export function documentToState(doc: Page): Omit<BuilderState, "isDirty" | "isSaving" | "saveError" | "lastSavedAt" | "selectedRowKey" | "selectedColumnKey" | "selectedBlockKey" | "_history" | "_future" | "_isTimeTraveling" | "_originalSlug" | "previewMode" | "canvasZoom" | "canvasPanX" | "canvasPanY" | "canvasTool" | "activeViewport" | "editorMode" | "customSectionSlug" | "customSectionTitle" | "savedPageState"> {
|
|
455
|
+
const docRecord = doc as unknown as Record<string, unknown>;
|
|
456
|
+
const pageSettings = docRecord.page_settings as Record<string, unknown> | undefined;
|
|
457
|
+
|
|
458
|
+
// BUG-014 fix: Track whether the Sanity document actually had page_settings saved.
|
|
459
|
+
// This lets applyGlobalStyles() know not to overwrite user-chosen colors.
|
|
460
|
+
const hasDocumentPageSettings = !!pageSettings;
|
|
461
|
+
|
|
462
|
+
// nav_color: free hex string (or legacy NavColorVariant — both accepted)
|
|
463
|
+
const nav_color = (typeof pageSettings?.nav_color === "string")
|
|
464
|
+
? (pageSettings.nav_color as string)
|
|
465
|
+
: "";
|
|
466
|
+
|
|
467
|
+
// Process content_rows with auto-migration
|
|
468
|
+
const rawRows = ensureKeys((doc.content_rows || []) as Array<{ _key?: string }>);
|
|
469
|
+
const rows: ContentItem[] = rawRows.map((item) =>
|
|
470
|
+
migrateContentItem(item as unknown as Record<string, unknown>)
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
pageId: doc._id,
|
|
475
|
+
pageTitle: doc.title || "",
|
|
476
|
+
pageSlug: doc.slug?.current || "",
|
|
477
|
+
pageType: doc.page_type || "page",
|
|
478
|
+
metadata: doc.metadata || {},
|
|
479
|
+
publishedAt: doc.published_at || null,
|
|
480
|
+
draftMode: doc.draft_mode ?? true,
|
|
481
|
+
rows,
|
|
482
|
+
_customSectionCache: {},
|
|
483
|
+
// BUG-014 fix: flag whether colors came from the document
|
|
484
|
+
_hasDocumentPageSettings: hasDocumentPageSettings,
|
|
485
|
+
pageSettings: {
|
|
486
|
+
background_color: (pageSettings?.background_color as string) || DEFAULT_BG_COLOR,
|
|
487
|
+
text_color: (pageSettings?.text_color as string) || DEFAULT_TEXT_COLOR,
|
|
488
|
+
nav_color,
|
|
489
|
+
enter_animation: pageSettings?.enter_animation as PageSettings["enter_animation"],
|
|
490
|
+
nav_entrance_animation: (pageSettings?.nav_entrance_animation as PageSettings["nav_entrance_animation"]) || undefined,
|
|
491
|
+
nav_entrance_duration: (pageSettings?.nav_entrance_duration as number) || undefined,
|
|
492
|
+
nav_entrance_delay: (pageSettings?.nav_entrance_delay as number) || undefined,
|
|
493
|
+
nav_entrance_disabled: (pageSettings?.nav_entrance_disabled as boolean) || undefined,
|
|
494
|
+
},
|
|
495
|
+
// Grid settings are loaded separately via applyGlobalStyles(), not from the page document.
|
|
496
|
+
// Provide defaults here to satisfy the type; they'll be overwritten on init.
|
|
497
|
+
gridSettings: {
|
|
498
|
+
width: DEFAULT_GRID_WIDTH,
|
|
499
|
+
outer_padding: "30",
|
|
500
|
+
gutter_desktop: "30",
|
|
501
|
+
gutter_responsive: "30",
|
|
502
|
+
gutter_phone: "16",
|
|
503
|
+
},
|
|
504
|
+
selectedProjectCardKey: null,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ============================================
|
|
509
|
+
// Builder State → Sanity Document
|
|
510
|
+
// ============================================
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Strip undefined values from an object (Sanity rejects undefined).
|
|
514
|
+
*/
|
|
515
|
+
function stripUndefined<T extends Record<string, unknown>>(obj: T): T {
|
|
516
|
+
const result = {} as T;
|
|
517
|
+
for (const key of Object.keys(obj) as (keyof T)[]) {
|
|
518
|
+
if (obj[key] !== undefined) {
|
|
519
|
+
result[key] = obj[key];
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return result;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function serializeBlock(block: ContentBlock): ContentBlock {
|
|
526
|
+
const serialized = stripUndefined({ ...block }) as ContentBlock;
|
|
527
|
+
|
|
528
|
+
// Normalize block responsive data (clean up empty layout overrides)
|
|
529
|
+
const blockRecord = serialized as unknown as Record<string, unknown>;
|
|
530
|
+
if (blockRecord.responsive) {
|
|
531
|
+
const normalized = normalizeBlockResponsive(
|
|
532
|
+
blockRecord.responsive as Record<string, Record<string, unknown>>
|
|
533
|
+
);
|
|
534
|
+
if (normalized) {
|
|
535
|
+
blockRecord.responsive = normalized;
|
|
536
|
+
} else {
|
|
537
|
+
delete blockRecord.responsive;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Normalize block layout responsive if present (layout.responsive sub-object)
|
|
542
|
+
if (blockRecord.layout && typeof blockRecord.layout === "object") {
|
|
543
|
+
const layout = blockRecord.layout as Record<string, unknown>;
|
|
544
|
+
// Strip undefined/null/empty string values from layout
|
|
545
|
+
const cleanLayout: Record<string, unknown> = {};
|
|
546
|
+
for (const [k, v] of Object.entries(layout)) {
|
|
547
|
+
if (v !== undefined && v !== null && v !== "") {
|
|
548
|
+
cleanLayout[k] = v;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
if (Object.keys(cleanLayout).length > 0) {
|
|
552
|
+
blockRecord.layout = cleanLayout;
|
|
553
|
+
} else {
|
|
554
|
+
delete blockRecord.layout;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Session 117: Ensure new animation fields are written
|
|
559
|
+
// enter_animation / hover_effect are already on the block from the store.
|
|
560
|
+
// Clean empty animation configs (preset=none or no preset → remove to keep Sanity clean)
|
|
561
|
+
const enterAnim = blockRecord.enter_animation as Record<string, unknown> | undefined;
|
|
562
|
+
if (enterAnim && (!enterAnim.preset || enterAnim.preset === "none")) {
|
|
563
|
+
delete blockRecord.enter_animation;
|
|
564
|
+
}
|
|
565
|
+
const hoverEff = blockRecord.hover_effect;
|
|
566
|
+
if (hoverEff && typeof hoverEff === "object") {
|
|
567
|
+
const he = hoverEff as Record<string, unknown>;
|
|
568
|
+
if (!he.preset || he.preset === "none") {
|
|
569
|
+
delete blockRecord.hover_effect;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return serialized;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Normalize section responsive overrides for serialization (save path).
|
|
578
|
+
* Strips undefined/null/empty string values and removes empty viewport objects.
|
|
579
|
+
*/
|
|
580
|
+
function normalizeSectionResponsiveForSerialize(
|
|
581
|
+
responsive: PageSection["responsive"] | undefined
|
|
582
|
+
): PageSection["responsive"] | undefined {
|
|
583
|
+
if (!responsive) return undefined;
|
|
584
|
+
|
|
585
|
+
const result: NonNullable<PageSection["responsive"]> = {};
|
|
586
|
+
|
|
587
|
+
for (const vp of ["tablet", "phone"] as const) {
|
|
588
|
+
const overrides = responsive[vp];
|
|
589
|
+
if (!overrides || typeof overrides !== "object") continue;
|
|
590
|
+
|
|
591
|
+
const cleaned: Record<string, unknown> = {};
|
|
592
|
+
for (const [key, val] of Object.entries(overrides)) {
|
|
593
|
+
if (val === undefined || val === null) continue;
|
|
594
|
+
if (typeof val === "string" && val.trim() === "") continue;
|
|
595
|
+
cleaned[key] = val;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (Object.keys(cleaned).length > 0) {
|
|
599
|
+
result[vp] = cleaned as typeof result[typeof vp];
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function serializePageSection(section: PageSection): Record<string, unknown> {
|
|
607
|
+
const s = section.settings;
|
|
608
|
+
return {
|
|
609
|
+
_key: section._key,
|
|
610
|
+
_type: "pageSection",
|
|
611
|
+
section_type: section.section_type,
|
|
612
|
+
block: (section.block || []).map((b) => serializeBlock(b as ContentBlock)),
|
|
613
|
+
// BUG-013 fix: persist responsive overrides for sections (normalized: strips empty values)
|
|
614
|
+
responsive: normalizeSectionResponsiveForSerialize(section.responsive),
|
|
615
|
+
settings: s ? stripUndefined({
|
|
616
|
+
background_color: s.background_color,
|
|
617
|
+
background_opacity: s.background_opacity,
|
|
618
|
+
background_image: s.background_image,
|
|
619
|
+
background_size: s.background_size,
|
|
620
|
+
background_position: s.background_position,
|
|
621
|
+
background_repeat: s.background_repeat,
|
|
622
|
+
spacing_top: s.spacing_top,
|
|
623
|
+
spacing_right: s.spacing_right,
|
|
624
|
+
spacing_bottom: s.spacing_bottom,
|
|
625
|
+
spacing_left: s.spacing_left,
|
|
626
|
+
offset_top: s.offset_top,
|
|
627
|
+
offset_right: s.offset_right,
|
|
628
|
+
offset_bottom: s.offset_bottom,
|
|
629
|
+
offset_left: s.offset_left,
|
|
630
|
+
border_color: s.border_color,
|
|
631
|
+
border_width: s.border_width,
|
|
632
|
+
border_style: s.border_style,
|
|
633
|
+
border_sides: s.border_sides,
|
|
634
|
+
border_radius: s.border_radius,
|
|
635
|
+
enter_animation: s.enter_animation,
|
|
636
|
+
}) : undefined,
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Normalize V2 section responsive overrides for serialization.
|
|
642
|
+
*/
|
|
643
|
+
function normalizeSectionV2ResponsiveForSerialize(
|
|
644
|
+
responsive: PageSectionV2["responsive"] | undefined
|
|
645
|
+
): PageSectionV2["responsive"] | undefined {
|
|
646
|
+
if (!responsive) return undefined;
|
|
647
|
+
|
|
648
|
+
const result: NonNullable<PageSectionV2["responsive"]> = {};
|
|
649
|
+
|
|
650
|
+
for (const vp of ["tablet", "phone"] as const) {
|
|
651
|
+
const overrides = responsive[vp];
|
|
652
|
+
if (!overrides || typeof overrides !== "object") continue;
|
|
653
|
+
|
|
654
|
+
const cleaned: Record<string, unknown> = {};
|
|
655
|
+
|
|
656
|
+
// Column overrides
|
|
657
|
+
if (overrides.columns && Array.isArray(overrides.columns) && overrides.columns.length > 0) {
|
|
658
|
+
const cleanedCols = overrides.columns.filter((co) => {
|
|
659
|
+
// Only keep overrides that have at least one meaningful value
|
|
660
|
+
return co.grid_column != null || co.grid_row != null || co.span != null;
|
|
661
|
+
});
|
|
662
|
+
if (cleanedCols.length > 0) {
|
|
663
|
+
cleaned.columns = cleanedCols;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Settings overrides
|
|
668
|
+
if (overrides.settings) {
|
|
669
|
+
const settingsCleaned: Record<string, unknown> = {};
|
|
670
|
+
for (const [key, val] of Object.entries(overrides.settings)) {
|
|
671
|
+
if (val === undefined || val === null) continue;
|
|
672
|
+
if (typeof val === "string" && val.trim() === "") continue;
|
|
673
|
+
settingsCleaned[key] = val;
|
|
674
|
+
}
|
|
675
|
+
if (Object.keys(settingsCleaned).length > 0) {
|
|
676
|
+
cleaned.settings = settingsCleaned;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (Object.keys(cleaned).length > 0) {
|
|
681
|
+
result[vp] = cleaned as typeof result[typeof vp];
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function serializePageSectionV2(section: PageSectionV2): Record<string, unknown> {
|
|
689
|
+
const s = section.settings;
|
|
690
|
+
return {
|
|
691
|
+
_key: section._key,
|
|
692
|
+
_type: "pageSectionV2",
|
|
693
|
+
section_type: section.section_type,
|
|
694
|
+
columns: section.columns.map((col) => {
|
|
695
|
+
const colData: Record<string, unknown> = {
|
|
696
|
+
_key: col._key,
|
|
697
|
+
_type: "sectionColumn",
|
|
698
|
+
grid_column: col.grid_column,
|
|
699
|
+
grid_row: col.grid_row,
|
|
700
|
+
span: col.span,
|
|
701
|
+
blocks: col.blocks.map(serializeBlock),
|
|
702
|
+
};
|
|
703
|
+
// Session 117: Column-level enter animation
|
|
704
|
+
if (col.enter_animation && col.enter_animation.preset && col.enter_animation.preset !== "none") {
|
|
705
|
+
colData.enter_animation = col.enter_animation;
|
|
706
|
+
}
|
|
707
|
+
return colData;
|
|
708
|
+
}),
|
|
709
|
+
settings: s ? stripUndefined({
|
|
710
|
+
preset: s.preset,
|
|
711
|
+
grid_columns: s.grid_columns,
|
|
712
|
+
col_gap: s.col_gap,
|
|
713
|
+
row_gap: s.row_gap,
|
|
714
|
+
spacing_top: s.spacing_top,
|
|
715
|
+
spacing_right: s.spacing_right,
|
|
716
|
+
spacing_bottom: s.spacing_bottom,
|
|
717
|
+
spacing_left: s.spacing_left,
|
|
718
|
+
offset_top: s.offset_top,
|
|
719
|
+
offset_right: s.offset_right,
|
|
720
|
+
offset_bottom: s.offset_bottom,
|
|
721
|
+
offset_left: s.offset_left,
|
|
722
|
+
background_color: s.background_color,
|
|
723
|
+
background_opacity: s.background_opacity,
|
|
724
|
+
background_image: s.background_image,
|
|
725
|
+
background_size: s.background_size,
|
|
726
|
+
background_position: s.background_position,
|
|
727
|
+
background_repeat: s.background_repeat,
|
|
728
|
+
border_color: s.border_color,
|
|
729
|
+
border_width: s.border_width,
|
|
730
|
+
border_style: s.border_style,
|
|
731
|
+
border_sides: s.border_sides,
|
|
732
|
+
border_radius: s.border_radius,
|
|
733
|
+
enter_animation: (s.enter_animation?.preset && s.enter_animation.preset !== "none")
|
|
734
|
+
? s.enter_animation : undefined,
|
|
735
|
+
stagger: s.stagger,
|
|
736
|
+
}) : undefined,
|
|
737
|
+
responsive: normalizeSectionV2ResponsiveForSerialize(section.responsive),
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Serialize a content item (PageSection or PageSectionV2) for Sanity.
|
|
743
|
+
*/
|
|
744
|
+
function serializeContentItem(item: ContentItem): Record<string, unknown> {
|
|
745
|
+
if (isPageSection(item)) {
|
|
746
|
+
return serializePageSection(item);
|
|
747
|
+
}
|
|
748
|
+
if (isPageSectionV2(item)) {
|
|
749
|
+
return serializePageSectionV2(item);
|
|
750
|
+
}
|
|
751
|
+
// CustomSectionInstance — serialize reference + per-instance overrides (Session 107, 130)
|
|
752
|
+
if (isCustomSectionInstance(item)) {
|
|
753
|
+
const out: Record<string, unknown> = {
|
|
754
|
+
_type: "customSectionInstance",
|
|
755
|
+
_key: item._key,
|
|
756
|
+
custom_section_id: item.custom_section_id,
|
|
757
|
+
custom_section_slug: item.custom_section_slug,
|
|
758
|
+
custom_section_title: item.custom_section_title,
|
|
759
|
+
};
|
|
760
|
+
if (item.settings_overrides && Object.keys(item.settings_overrides).length > 0) {
|
|
761
|
+
out.settings_overrides = item.settings_overrides;
|
|
762
|
+
}
|
|
763
|
+
if (item.responsive_overrides) {
|
|
764
|
+
// Only write if there's actual data (at least one viewport with content)
|
|
765
|
+
const r = item.responsive_overrides;
|
|
766
|
+
const hasTablet = r.tablet && (r.tablet.settings && Object.keys(r.tablet.settings).length > 0 || r.tablet.columns && r.tablet.columns.length > 0);
|
|
767
|
+
const hasPhone = r.phone && (r.phone.settings && Object.keys(r.phone.settings).length > 0 || r.phone.columns && r.phone.columns.length > 0);
|
|
768
|
+
if (hasTablet || hasPhone) {
|
|
769
|
+
out.responsive_overrides = item.responsive_overrides;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
return out;
|
|
773
|
+
}
|
|
774
|
+
// ParallaxGroup — serialize slides with their V2 section data (Session 123)
|
|
775
|
+
if (isParallaxGroup(item)) {
|
|
776
|
+
return serializeParallaxGroup(item);
|
|
777
|
+
}
|
|
778
|
+
// Should never reach here
|
|
779
|
+
throw new Error(`[Serializer] Unknown content item type during serialization`);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Convert the builder's state into a Sanity-compatible document payload.
|
|
784
|
+
* Used by the save API route.
|
|
785
|
+
*/
|
|
786
|
+
export function stateToDocument(
|
|
787
|
+
state: Pick<BuilderState, "pageId" | "pageTitle" | "pageSlug" | "pageType" | "metadata" | "draftMode" | "publishedAt" | "rows" | "pageSettings">
|
|
788
|
+
): Record<string, unknown> {
|
|
789
|
+
// BUG-008 fix: Always persist page_settings when ANY field has a non-default value.
|
|
790
|
+
// Previously, animation configs with preset='none' but other fields set (intensity, easing)
|
|
791
|
+
// would be stripped. Now we check for the presence of any animation config object,
|
|
792
|
+
// not just whether its preset is 'none'.
|
|
793
|
+
const ps = state.pageSettings;
|
|
794
|
+
const bgColor = ps?.background_color || DEFAULT_BG_COLOR;
|
|
795
|
+
const txtColor = ps?.text_color || DEFAULT_TEXT_COLOR;
|
|
796
|
+
const navColor = ps?.nav_color || "";
|
|
797
|
+
|
|
798
|
+
const enterAnim = ps?.enter_animation;
|
|
799
|
+
const hasEnterAnimation = enterAnim && enterAnim.preset && enterAnim.preset !== 'none';
|
|
800
|
+
|
|
801
|
+
const navEntranceAnimation = ps?.nav_entrance_animation || "";
|
|
802
|
+
const navEntranceDuration = ps?.nav_entrance_duration;
|
|
803
|
+
const navEntranceDelay = ps?.nav_entrance_delay;
|
|
804
|
+
const navEntranceDisabled = ps?.nav_entrance_disabled;
|
|
805
|
+
|
|
806
|
+
// Persist whenever any value differs from deserialisation defaults
|
|
807
|
+
const hasPageSettings =
|
|
808
|
+
bgColor !== DEFAULT_BG_COLOR ||
|
|
809
|
+
txtColor !== DEFAULT_TEXT_COLOR ||
|
|
810
|
+
!!navColor ||
|
|
811
|
+
!!hasEnterAnimation ||
|
|
812
|
+
!!enterAnim ||
|
|
813
|
+
!!navEntranceAnimation ||
|
|
814
|
+
!!navEntranceDuration ||
|
|
815
|
+
!!navEntranceDelay ||
|
|
816
|
+
!!navEntranceDisabled;
|
|
817
|
+
|
|
818
|
+
return stripUndefined({
|
|
819
|
+
_id: state.pageId,
|
|
820
|
+
_type: "page",
|
|
821
|
+
title: state.pageTitle,
|
|
822
|
+
slug: { _type: "slug", current: state.pageSlug },
|
|
823
|
+
page_type: state.pageType,
|
|
824
|
+
content_rows: state.rows.map(serializeContentItem),
|
|
825
|
+
metadata: stripUndefined({
|
|
826
|
+
seo_title: state.metadata.seo_title,
|
|
827
|
+
seo_description: state.metadata.seo_description,
|
|
828
|
+
og_image_path: state.metadata.og_image_path,
|
|
829
|
+
}),
|
|
830
|
+
page_settings: hasPageSettings
|
|
831
|
+
? {
|
|
832
|
+
background_color: bgColor,
|
|
833
|
+
text_color: txtColor,
|
|
834
|
+
nav_color: navColor || undefined,
|
|
835
|
+
enter_animation: hasEnterAnimation ? enterAnim : undefined,
|
|
836
|
+
nav_entrance_animation: navEntranceAnimation || undefined,
|
|
837
|
+
nav_entrance_duration: navEntranceDuration || undefined,
|
|
838
|
+
nav_entrance_delay: navEntranceDelay || undefined,
|
|
839
|
+
nav_entrance_disabled: navEntranceDisabled || undefined,
|
|
840
|
+
}
|
|
841
|
+
: undefined,
|
|
842
|
+
draft_mode: state.draftMode,
|
|
843
|
+
published_at: state.publishedAt,
|
|
844
|
+
});
|
|
845
|
+
}
|