@morphika/andami 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (319) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -0
  3. package/admin/assets.ts +4 -0
  4. package/admin/database.ts +4 -0
  5. package/admin/index.ts +6 -0
  6. package/admin/login.ts +4 -0
  7. package/admin/navigation.ts +4 -0
  8. package/admin/pages-editor.ts +4 -0
  9. package/admin/pages.ts +4 -0
  10. package/admin/projects-editor.ts +4 -0
  11. package/admin/projects.ts +4 -0
  12. package/admin/settings.ts +4 -0
  13. package/admin/setup.ts +4 -0
  14. package/admin/storage.ts +4 -0
  15. package/admin/styles.ts +4 -0
  16. package/app/(site)/[slug]/loading.tsx +20 -0
  17. package/app/(site)/[slug]/page.tsx +83 -0
  18. package/app/(site)/error.tsx +32 -0
  19. package/app/(site)/layout.tsx +53 -0
  20. package/app/(site)/loading.tsx +20 -0
  21. package/app/(site)/not-found.tsx +41 -0
  22. package/app/(site)/page.tsx +43 -0
  23. package/app/(site)/preview/page.tsx +99 -0
  24. package/app/(site)/work/[slug]/loading.tsx +23 -0
  25. package/app/(site)/work/[slug]/page.tsx +84 -0
  26. package/app/admin/assets/page.tsx +573 -0
  27. package/app/admin/database/page.tsx +302 -0
  28. package/app/admin/error.tsx +53 -0
  29. package/app/admin/layout.tsx +273 -0
  30. package/app/admin/login/page.tsx +88 -0
  31. package/app/admin/navigation/page.tsx +157 -0
  32. package/app/admin/page.tsx +17 -0
  33. package/app/admin/pages/[slug]/page.tsx +849 -0
  34. package/app/admin/pages/page.tsx +588 -0
  35. package/app/admin/projects/[slug]/page.tsx +3 -0
  36. package/app/admin/projects/page.tsx +669 -0
  37. package/app/admin/settings/page.tsx +132 -0
  38. package/app/admin/setup/page.tsx +64 -0
  39. package/app/admin/storage/page.tsx +518 -0
  40. package/app/admin/styles/page.tsx +243 -0
  41. package/app/api/admin/assets/file/route.ts +81 -0
  42. package/app/api/admin/assets/health/route.ts +170 -0
  43. package/app/api/admin/assets/register/route.ts +163 -0
  44. package/app/api/admin/assets/registry/route.ts +98 -0
  45. package/app/api/admin/assets/relink/confirm/route.ts +242 -0
  46. package/app/api/admin/assets/relink/route.ts +202 -0
  47. package/app/api/admin/assets/scan/route.ts +271 -0
  48. package/app/api/admin/auth/route.ts +160 -0
  49. package/app/api/admin/custom-sections/[slug]/route.ts +159 -0
  50. package/app/api/admin/custom-sections/route.ts +127 -0
  51. package/app/api/admin/database/route.ts +53 -0
  52. package/app/api/admin/pages/[slug]/duplicate/route.ts +91 -0
  53. package/app/api/admin/pages/[slug]/route.ts +617 -0
  54. package/app/api/admin/pages/[slug]/set-home/route.ts +76 -0
  55. package/app/api/admin/pages/route.ts +129 -0
  56. package/app/api/admin/preview/route.ts +53 -0
  57. package/app/api/admin/r2/connect/route.ts +181 -0
  58. package/app/api/admin/r2/delete/route.ts +198 -0
  59. package/app/api/admin/r2/disconnect/route.ts +42 -0
  60. package/app/api/admin/r2/rename/route.ts +265 -0
  61. package/app/api/admin/r2/status/route.ts +106 -0
  62. package/app/api/admin/r2/upload-url/route.ts +148 -0
  63. package/app/api/admin/revalidate/route.ts +55 -0
  64. package/app/api/admin/settings/route.ts +279 -0
  65. package/app/api/admin/setup/complete/route.ts +51 -0
  66. package/app/api/admin/setup/route.ts +118 -0
  67. package/app/api/admin/storage/switch/route.ts +117 -0
  68. package/app/api/admin/styles/fonts/route.ts +97 -0
  69. package/app/api/admin/styles/route.ts +304 -0
  70. package/app/api/assets/[...path]/route.ts +98 -0
  71. package/app/api/custom-sections/[id]/route.ts +43 -0
  72. package/app/api/draft-mode/disable/route.ts +10 -0
  73. package/app/api/draft-mode/enable/route.ts +26 -0
  74. package/app/api/projects/route.ts +42 -0
  75. package/app/api/styles/route.ts +88 -0
  76. package/app/favicon.ico +0 -0
  77. package/app/globals.css +7 -0
  78. package/app/layout.tsx +53 -0
  79. package/app/robots.ts +17 -0
  80. package/app/sitemap.ts +48 -0
  81. package/app/studio/[[...index]]/page.tsx +8 -0
  82. package/components/admin/MetadataEditor.tsx +173 -0
  83. package/components/admin/PublishToggle.tsx +130 -0
  84. package/components/admin/icons.tsx +40 -0
  85. package/components/admin/nav-builder/NavBuilder.tsx +182 -0
  86. package/components/admin/nav-builder/NavBuilderGrid.tsx +326 -0
  87. package/components/admin/nav-builder/NavGeneralSettings.tsx +275 -0
  88. package/components/admin/nav-builder/NavGridCell.tsx +48 -0
  89. package/components/admin/nav-builder/NavGridItem.tsx +189 -0
  90. package/components/admin/nav-builder/NavItemSettings.tsx +288 -0
  91. package/components/admin/nav-builder/NavItemTypePicker.tsx +102 -0
  92. package/components/admin/nav-builder/NavLivePreview.tsx +125 -0
  93. package/components/admin/nav-builder/NavSettingsFields.tsx +248 -0
  94. package/components/admin/nav-builder/NavSettingsPanel.tsx +127 -0
  95. package/components/admin/nav-builder/index.ts +10 -0
  96. package/components/admin/nav-builder/nav-builder-utils.ts +238 -0
  97. package/components/admin/setup-wizard/BrandingStep.tsx +218 -0
  98. package/components/admin/setup-wizard/DatabaseStep.tsx +331 -0
  99. package/components/admin/setup-wizard/DoneStep.tsx +187 -0
  100. package/components/admin/setup-wizard/SetupWizard.tsx +166 -0
  101. package/components/admin/setup-wizard/StorageStep.tsx +308 -0
  102. package/components/admin/setup-wizard/WelcomeStep.tsx +96 -0
  103. package/components/admin/setup-wizard/index.ts +9 -0
  104. package/components/admin/styles/ColorsEditor.tsx +214 -0
  105. package/components/admin/styles/FontsEditor.tsx +258 -0
  106. package/components/admin/styles/GridLayoutEditor.tsx +292 -0
  107. package/components/admin/styles/LinksButtonsEditor.tsx +120 -0
  108. package/components/admin/styles/TypographyEditor.tsx +266 -0
  109. package/components/admin/styles/index.ts +9 -0
  110. package/components/admin/styles/shared.tsx +68 -0
  111. package/components/blocks/BlockRenderer.tsx +404 -0
  112. package/components/blocks/ButtonBlockRenderer.tsx +52 -0
  113. package/components/blocks/CoverBlockRenderer.tsx +239 -0
  114. package/components/blocks/CustomSectionInstanceRenderer.tsx +82 -0
  115. package/components/blocks/EnterAnimationWrapper.tsx +140 -0
  116. package/components/blocks/HoverAnimationWrapper.tsx +308 -0
  117. package/components/blocks/ImageBlockRenderer.tsx +61 -0
  118. package/components/blocks/ImageGridBlockRenderer.tsx +545 -0
  119. package/components/blocks/PageBackground.tsx +28 -0
  120. package/components/blocks/PageNavAnimation.tsx +35 -0
  121. package/components/blocks/PageNavColor.tsx +24 -0
  122. package/components/blocks/PageRenderer.tsx +142 -0
  123. package/components/blocks/ParallaxGroupRenderer.tsx +448 -0
  124. package/components/blocks/ParallaxSlideRenderer.tsx +175 -0
  125. package/components/blocks/ProjectGridBlockRenderer.tsx +556 -0
  126. package/components/blocks/SectionRenderer.tsx +170 -0
  127. package/components/blocks/SectionV2Renderer.tsx +330 -0
  128. package/components/blocks/ShaderCanvas.tsx +392 -0
  129. package/components/blocks/SpacerBlockRenderer.tsx +17 -0
  130. package/components/blocks/TextBlockRenderer.tsx +87 -0
  131. package/components/blocks/TypewriterRichText.tsx +464 -0
  132. package/components/blocks/TypewriterWrapper.tsx +149 -0
  133. package/components/blocks/VideoBlockRenderer.tsx +304 -0
  134. package/components/blocks/index.ts +2 -0
  135. package/components/builder/AssetBrowser.tsx +2 -0
  136. package/components/builder/BlockLivePreview.tsx +101 -0
  137. package/components/builder/BlockTypePicker.tsx +178 -0
  138. package/components/builder/BuilderCanvas.tsx +354 -0
  139. package/components/builder/CanvasMinimap.tsx +200 -0
  140. package/components/builder/CanvasToolbar.tsx +202 -0
  141. package/components/builder/ColorPicker.tsx +243 -0
  142. package/components/builder/ColorSwatchPicker.tsx +274 -0
  143. package/components/builder/ColumnDragContext.tsx +51 -0
  144. package/components/builder/ColumnDragOverlay.tsx +110 -0
  145. package/components/builder/CustomSectionInstanceCard.tsx +97 -0
  146. package/components/builder/DeviceFrame.tsx +123 -0
  147. package/components/builder/DndWrapper.tsx +337 -0
  148. package/components/builder/InsertionLines.tsx +186 -0
  149. package/components/builder/ParallaxGroupCanvas.tsx +228 -0
  150. package/components/builder/ParallaxSlideHeader.tsx +113 -0
  151. package/components/builder/ReadOnlyFrame.tsx +417 -0
  152. package/components/builder/SectionEditorBar.tsx +288 -0
  153. package/components/builder/SectionTypePicker.tsx +422 -0
  154. package/components/builder/SectionV2Canvas.tsx +297 -0
  155. package/components/builder/SectionV2Column.tsx +488 -0
  156. package/components/builder/SettingsPanel.tsx +911 -0
  157. package/components/builder/SortableBlock.tsx +230 -0
  158. package/components/builder/SortableRow.tsx +362 -0
  159. package/components/builder/VirtualAssetGrid.tsx +397 -0
  160. package/components/builder/asset-browser/AssetBrowser.tsx +178 -0
  161. package/components/builder/asset-browser/FileLightbox.tsx +116 -0
  162. package/components/builder/asset-browser/FolderTreeItem.tsx +55 -0
  163. package/components/builder/asset-browser/R2BrowserContent.tsx +436 -0
  164. package/components/builder/asset-browser/R2ContextMenu.tsx +98 -0
  165. package/components/builder/asset-browser/VideoThumbnail.tsx +63 -0
  166. package/components/builder/asset-browser/helpers.ts +88 -0
  167. package/components/builder/asset-browser/index.ts +1 -0
  168. package/components/builder/asset-browser/types.ts +49 -0
  169. package/components/builder/asset-browser/useAssetBrowser.ts +344 -0
  170. package/components/builder/asset-browser/useR2DragDrop.ts +116 -0
  171. package/components/builder/asset-browser/useR2Operations.ts +189 -0
  172. package/components/builder/blockStyles.tsx +295 -0
  173. package/components/builder/editors/ButtonBlockEditor.tsx +184 -0
  174. package/components/builder/editors/CoverBlockEditor.tsx +488 -0
  175. package/components/builder/editors/EnterAnimationPicker.tsx +297 -0
  176. package/components/builder/editors/HoverEffectPicker.tsx +209 -0
  177. package/components/builder/editors/ImageBlockEditor.tsx +206 -0
  178. package/components/builder/editors/ImageGridBlockEditor.tsx +386 -0
  179. package/components/builder/editors/ProjectGridEditor.tsx +648 -0
  180. package/components/builder/editors/SpacerBlockEditor.tsx +167 -0
  181. package/components/builder/editors/StaggerSettings.tsx +108 -0
  182. package/components/builder/editors/TextAlignmentIcons.tsx +39 -0
  183. package/components/builder/editors/TextBlockEditor.tsx +462 -0
  184. package/components/builder/editors/TextStylePicker.tsx +183 -0
  185. package/components/builder/editors/VideoBlockEditor.tsx +278 -0
  186. package/components/builder/editors/index.ts +10 -0
  187. package/components/builder/editors/shared.tsx +345 -0
  188. package/components/builder/hooks/useColumnDrag.ts +472 -0
  189. package/components/builder/hooks/useColumnResize.ts +221 -0
  190. package/components/builder/index.ts +12 -0
  191. package/components/builder/live-preview/LiveButtonPreview.tsx +38 -0
  192. package/components/builder/live-preview/LiveCoverPreview.tsx +146 -0
  193. package/components/builder/live-preview/LiveImageGridPreview.tsx +123 -0
  194. package/components/builder/live-preview/LiveImagePreview.tsx +107 -0
  195. package/components/builder/live-preview/LiveProjectGridPreview.tsx +1010 -0
  196. package/components/builder/live-preview/LiveSpacerPreview.tsx +9 -0
  197. package/components/builder/live-preview/LiveTextEditor.tsx +198 -0
  198. package/components/builder/live-preview/LiveVideoPreview.tsx +98 -0
  199. package/components/builder/live-preview/index.ts +10 -0
  200. package/components/builder/live-preview/shared.tsx +153 -0
  201. package/components/builder/settings-panel/BlockLayoutTab.tsx +532 -0
  202. package/components/builder/settings-panel/BlockSettings.tsx +94 -0
  203. package/components/builder/settings-panel/ColumnV2Settings.tsx +160 -0
  204. package/components/builder/settings-panel/LayoutTab.tsx +310 -0
  205. package/components/builder/settings-panel/PageSettings.tsx +200 -0
  206. package/components/builder/settings-panel/ParallaxGroupSettings.tsx +118 -0
  207. package/components/builder/settings-panel/ParallaxSlideSettings.tsx +178 -0
  208. package/components/builder/settings-panel/SectionV2AnimationTab.tsx +103 -0
  209. package/components/builder/settings-panel/SectionV2LayoutTab.tsx +312 -0
  210. package/components/builder/settings-panel/SectionV2Settings.tsx +323 -0
  211. package/components/builder/settings-panel/TRBLInputs.tsx +51 -0
  212. package/components/builder/settings-panel/index.ts +19 -0
  213. package/components/builder/settings-panel/responsive-helpers.ts +524 -0
  214. package/components/ui/CustomCursor.tsx +118 -0
  215. package/components/ui/NavContentLightbox.tsx +152 -0
  216. package/components/ui/Navbar.tsx +582 -0
  217. package/components/ui/PortfolioTracker.tsx +87 -0
  218. package/components/ui/ScrollToTop.tsx +47 -0
  219. package/lib/animation/enter-presets.ts +147 -0
  220. package/lib/animation/enter-resolve.ts +90 -0
  221. package/lib/animation/enter-types.ts +128 -0
  222. package/lib/animation/hover-effect-presets.ts +210 -0
  223. package/lib/animation/hover-effect-types.ts +126 -0
  224. package/lib/asset-retry.ts +111 -0
  225. package/lib/assets.ts +92 -0
  226. package/lib/audit.ts +35 -0
  227. package/lib/auth-token.ts +94 -0
  228. package/lib/auth.ts +13 -0
  229. package/lib/builder/cascade-helpers.ts +51 -0
  230. package/lib/builder/cascade.ts +533 -0
  231. package/lib/builder/constants.ts +103 -0
  232. package/lib/builder/defaults.ts +182 -0
  233. package/lib/builder/history.ts +48 -0
  234. package/lib/builder/index.ts +21 -0
  235. package/lib/builder/layout-styles.ts +344 -0
  236. package/lib/builder/masonry.ts +166 -0
  237. package/lib/builder/responsive.ts +156 -0
  238. package/lib/builder/serializer.ts +845 -0
  239. package/lib/builder/store-blocks.ts +193 -0
  240. package/lib/builder/store-canvas.ts +319 -0
  241. package/lib/builder/store-helpers.ts +490 -0
  242. package/lib/builder/store-sections.ts +709 -0
  243. package/lib/builder/store.ts +333 -0
  244. package/lib/builder/templates.ts +297 -0
  245. package/lib/builder/types.ts +374 -0
  246. package/lib/builder/utils.ts +37 -0
  247. package/lib/color-utils.ts +116 -0
  248. package/lib/config/index.ts +57 -0
  249. package/lib/config/types.ts +122 -0
  250. package/lib/contexts/AssetContext.tsx +79 -0
  251. package/lib/contexts/NavAnimationContext.tsx +44 -0
  252. package/lib/contexts/NavColorContext.tsx +38 -0
  253. package/lib/contexts/PageExitContext.tsx +194 -0
  254. package/lib/contexts/ThumbStatusContext.tsx +83 -0
  255. package/lib/csrf-client.ts +34 -0
  256. package/lib/csrf.ts +68 -0
  257. package/lib/format-utils.ts +24 -0
  258. package/lib/hooks/useViewport.ts +42 -0
  259. package/lib/logger.ts +81 -0
  260. package/lib/revalidate.ts +23 -0
  261. package/lib/sanitize.ts +91 -0
  262. package/lib/sanity/client.ts +8 -0
  263. package/lib/sanity/queries.ts +486 -0
  264. package/lib/sanity/types.ts +869 -0
  265. package/lib/sanity/writeClient.ts +24 -0
  266. package/lib/security.ts +402 -0
  267. package/lib/setup/detect.ts +156 -0
  268. package/lib/shader/glsl/index.ts +27 -0
  269. package/lib/shader/glsl/pixelate.ts +51 -0
  270. package/lib/shader/glsl/rgb-shift.ts +45 -0
  271. package/lib/shader/glsl/ripple.ts +46 -0
  272. package/lib/shader/glsl/vertex.ts +14 -0
  273. package/lib/storage/index.ts +211 -0
  274. package/lib/storage/r2-adapter.ts +286 -0
  275. package/lib/storage/types.ts +125 -0
  276. package/lib/styles/provider.tsx +267 -0
  277. package/lib/thumbnails/generate.ts +151 -0
  278. package/lib/utils.ts +6 -0
  279. package/package.json +212 -0
  280. package/sanity/compose.ts +65 -0
  281. package/sanity/sanity.config.ts +126 -0
  282. package/sanity/schemas/assetRegistry.ts +301 -0
  283. package/sanity/schemas/blocks/blockLayout.ts +90 -0
  284. package/sanity/schemas/blocks/buttonBlock.ts +82 -0
  285. package/sanity/schemas/blocks/coverBlock.ts +229 -0
  286. package/sanity/schemas/blocks/imageBlock.ts +58 -0
  287. package/sanity/schemas/blocks/imageGridBlock.ts +112 -0
  288. package/sanity/schemas/blocks/index.ts +9 -0
  289. package/sanity/schemas/blocks/projectGridBlock.ts +251 -0
  290. package/sanity/schemas/blocks/spacerBlock.ts +41 -0
  291. package/sanity/schemas/blocks/textBlock.ts +139 -0
  292. package/sanity/schemas/blocks/videoBlock.ts +80 -0
  293. package/sanity/schemas/customSection.ts +69 -0
  294. package/sanity/schemas/customSectionInstance.ts +163 -0
  295. package/sanity/schemas/index.ts +111 -0
  296. package/sanity/schemas/objects/enterAnimationConfig.ts +72 -0
  297. package/sanity/schemas/objects/hoverEffectConfig.ts +90 -0
  298. package/sanity/schemas/objects/parallaxGroup.ts +66 -0
  299. package/sanity/schemas/objects/parallaxSlide.ts +217 -0
  300. package/sanity/schemas/objects/typewriterConfig.ts +38 -0
  301. package/sanity/schemas/page.ts +162 -0
  302. package/sanity/schemas/pageSection.ts +157 -0
  303. package/sanity/schemas/pageSectionV2.ts +269 -0
  304. package/sanity/schemas/siteSettings.ts +256 -0
  305. package/sanity/schemas/siteStyles.ts +212 -0
  306. package/site/error.ts +4 -0
  307. package/site/index.ts +8 -0
  308. package/site/not-found.ts +4 -0
  309. package/site/page.ts +4 -0
  310. package/site/preview.ts +4 -0
  311. package/site/robots.ts +4 -0
  312. package/site/sitemap.ts +4 -0
  313. package/site/work.ts +4 -0
  314. package/studio/index.ts +4 -0
  315. package/styles/admin.css +85 -0
  316. package/styles/animations.css +237 -0
  317. package/styles/base.css +148 -0
  318. package/styles/globals.css +10 -0
  319. package/tsconfig.json +25 -0
@@ -0,0 +1,709 @@
1
+ import type { BuilderStore, BuilderState, BlockType } from "./types";
2
+ import type {
3
+ ContentBlock,
4
+ ContentItem,
5
+ PageSection,
6
+ PageSectionV2,
7
+ SectionV2Settings,
8
+ SectionSettings,
9
+ SectionBlock,
10
+ CustomSectionInstance,
11
+ ParallaxGroup,
12
+ ParallaxSlideV2,
13
+ SectionV2Preset,
14
+ EnterAnimationConfig,
15
+ } from "../../lib/sanity/types";
16
+ import {
17
+ isPageSection,
18
+ isPageSectionV2,
19
+ isCustomSectionInstance,
20
+ isParallaxGroup,
21
+ } from "../../lib/sanity/types";
22
+ import { createDefaultBlock, createDefaultParallaxSlide } from "./defaults";
23
+ import { generateKey } from "./utils";
24
+ import {
25
+ computeCascade,
26
+ detectPreset,
27
+ columnsFromPreset,
28
+ addColumn as cascadeAddColumn,
29
+ deleteColumn as cascadeDeleteColumn,
30
+ resizeColumn as cascadeResizeColumn,
31
+ } from "./cascade";
32
+ import { applyBlocksToColumns, toCascadeColumns } from "./cascade-helpers";
33
+ import {
34
+ addSectionV2InState,
35
+ findSectionPath,
36
+ getSectionFromPath,
37
+ updateSectionAtPath,
38
+ addParallaxGroupInState,
39
+ resizeColumnV2LeftInState,
40
+ moveColumnV2InState,
41
+ swapColumnV2InState,
42
+ moveColumnToGapV2InState,
43
+ } from "./store-helpers";
44
+
45
+ type StoreSet = (
46
+ partial: Partial<BuilderState> | ((state: BuilderState) => Partial<BuilderState>)
47
+ ) => void;
48
+ type StoreGet = () => BuilderStore;
49
+
50
+ export function createSectionActions(set: StoreSet, get: StoreGet) {
51
+ return {
52
+ // ---- Section operations ----
53
+
54
+ /**
55
+ * Add a "Page Section" — creates a first-class PageSection entity
56
+ * with a pre-populated section block (projectGridBlock).
57
+ * No row/column wrapper — direct page-level section.
58
+ */
59
+ addSection: (blockType: "projectGridBlock", afterRowKey?: string | null): void => {
60
+ get()._pushSnapshot();
61
+ const block = createDefaultBlock(blockType) as SectionBlock;
62
+ const newSection: PageSection = {
63
+ _type: "pageSection",
64
+ _key: generateKey(),
65
+ section_type: "projectGrid",
66
+ block: [block],
67
+ // BUG-011 fix: default spacing to match addRow() defaults
68
+ settings: {
69
+ spacing_top: "32",
70
+ spacing_bottom: "32",
71
+ },
72
+ };
73
+
74
+ set((state) => {
75
+ const rows = [...state.rows];
76
+ if (afterRowKey) {
77
+ const idx = rows.findIndex((r) => r._key === afterRowKey);
78
+ if (idx !== -1) {
79
+ rows.splice(idx + 1, 0, newSection);
80
+ } else {
81
+ rows.push(newSection);
82
+ }
83
+ } else {
84
+ rows.push(newSection);
85
+ }
86
+ return { rows, isDirty: true, selectedRowKey: newSection._key };
87
+ });
88
+ },
89
+
90
+ reorderRows: (fromIndex: number, toIndex: number): void => {
91
+ get()._pushSnapshot();
92
+ set((state) => {
93
+ const rows = [...state.rows];
94
+ const [moved] = rows.splice(fromIndex, 1);
95
+ rows.splice(toIndex, 0, moved);
96
+ return { rows, isDirty: true };
97
+ });
98
+ },
99
+
100
+ updateSectionSettings: (
101
+ sectionKey: string,
102
+ settings: Partial<SectionSettings>
103
+ ): void => {
104
+ set((state) => ({
105
+ rows: state.rows.map((item) =>
106
+ item._key === sectionKey && isPageSection(item)
107
+ ? { ...item, settings: { ...item.settings, ...settings } }
108
+ : item
109
+ ),
110
+ isDirty: true,
111
+ }));
112
+ },
113
+
114
+ /** BUG-013 fix: Update responsive overrides for a PageSection */
115
+ updateSectionResponsive: (
116
+ sectionKey: string,
117
+ responsive: PageSection["responsive"]
118
+ ): void => {
119
+ set((state) => ({
120
+ rows: state.rows.map((item) =>
121
+ item._key === sectionKey && isPageSection(item)
122
+ ? { ...item, responsive }
123
+ : item
124
+ ),
125
+ isDirty: true,
126
+ }));
127
+ },
128
+
129
+ updateSectionBlock: (sectionKey: string, updates: Partial<ContentBlock>): void => {
130
+ set((state) => ({
131
+ rows: state.rows.map((item) => {
132
+ if (item._key !== sectionKey || !isPageSection(item)) return item;
133
+ const block = item.block[0];
134
+ if (!block) return item;
135
+ return {
136
+ ...item,
137
+ block: [{ ...block, ...updates } as SectionBlock],
138
+ };
139
+ }),
140
+ isDirty: true,
141
+ }));
142
+ },
143
+
144
+ deleteSection: (sectionKey: string): void => {
145
+ get()._pushSnapshot();
146
+ set((state) => ({
147
+ rows: state.rows.filter((item) => item._key !== sectionKey),
148
+ selectedRowKey:
149
+ state.selectedRowKey === sectionKey ? null : state.selectedRowKey,
150
+ selectedColumnKey:
151
+ state.selectedRowKey === sectionKey ? null : state.selectedColumnKey,
152
+ selectedBlockKey:
153
+ state.selectedRowKey === sectionKey ? null : state.selectedBlockKey,
154
+ isDirty: true,
155
+ }));
156
+ },
157
+
158
+ duplicateSection: (sectionKey: string): void => {
159
+ get()._pushSnapshot();
160
+ set((state) => {
161
+ const idx = state.rows.findIndex((item) => item._key === sectionKey);
162
+ if (idx === -1) return state;
163
+ const original = state.rows[idx];
164
+ // Deep clone and assign new keys
165
+ const clone = JSON.parse(JSON.stringify(original)) as ContentItem;
166
+ clone._key = generateKey();
167
+ // Re-key internal structures
168
+ if (isPageSection(clone)) {
169
+ if (clone.block.length > 0) {
170
+ clone.block = [
171
+ { ...clone.block[0], _key: generateKey() },
172
+ ] as [SectionBlock];
173
+ }
174
+ } else if (isPageSectionV2(clone)) {
175
+ (clone as PageSectionV2).columns = (clone as PageSectionV2).columns.map(
176
+ (col) => ({
177
+ ...col,
178
+ _key: generateKey(),
179
+ blocks: col.blocks.map((b) => ({ ...b, _key: generateKey() })),
180
+ })
181
+ );
182
+ } else if (isParallaxGroup(clone)) {
183
+ const group = clone as ParallaxGroup;
184
+ group.slides = group.slides.map((slide) => ({
185
+ ...slide,
186
+ _key: generateKey(),
187
+ columns: slide.columns.map((col) => ({
188
+ ...col,
189
+ _key: generateKey(),
190
+ blocks: col.blocks.map((b) => ({ ...b, _key: generateKey() })),
191
+ })),
192
+ }));
193
+ }
194
+ const rows = [...state.rows];
195
+ rows.splice(idx + 1, 0, clone);
196
+ return {
197
+ rows,
198
+ selectedRowKey: clone._key,
199
+ selectedColumnKey: null,
200
+ selectedBlockKey: null,
201
+ isDirty: true,
202
+ };
203
+ });
204
+ },
205
+
206
+ // ---- V2 Section operations ----
207
+
208
+ addSectionV2: (preset: SectionV2Preset, afterRowKey?: string | null): void => {
209
+ get()._pushSnapshot();
210
+
211
+ const result = addSectionV2InState(get().rows, preset, afterRowKey);
212
+ set({ rows: result.rows, isDirty: true, selectedRowKey: result.newSectionKey });
213
+ },
214
+
215
+ addColumnV2: (sectionKey: string, gridRow: number, gridColumn: number, span: number): void => {
216
+ get()._pushSnapshot();
217
+
218
+ set((state) => {
219
+ const path = findSectionPath(state.rows, sectionKey);
220
+ if (!path) return state;
221
+ const rows = updateSectionAtPath(state.rows, path, (section) => {
222
+ const newKey = generateKey();
223
+ const updatedCascade = cascadeAddColumn(
224
+ toCascadeColumns(section.columns),
225
+ gridRow,
226
+ gridColumn,
227
+ span,
228
+ newKey,
229
+ section.settings.grid_columns
230
+ );
231
+ const updatedColumns = applyBlocksToColumns(
232
+ updatedCascade,
233
+ section.columns
234
+ );
235
+ const newPreset = detectPreset(
236
+ updatedCascade,
237
+ section.settings.grid_columns
238
+ );
239
+ return {
240
+ ...section,
241
+ columns: updatedColumns,
242
+ settings: { ...section.settings, preset: newPreset },
243
+ };
244
+ });
245
+ return { rows, isDirty: true };
246
+ });
247
+ },
248
+
249
+ deleteColumnV2: (sectionKey: string, columnKey: string): void => {
250
+ get()._pushSnapshot();
251
+
252
+ set((state) => {
253
+ const path = findSectionPath(state.rows, sectionKey);
254
+ if (!path) return state;
255
+ const rows = updateSectionAtPath(state.rows, path, (section) => {
256
+ const updatedColumns = section.columns.filter(
257
+ (c) => c._key !== columnKey
258
+ );
259
+ const cascadeCols = updatedColumns.map((c) => ({
260
+ _key: c._key,
261
+ grid_column: c.grid_column,
262
+ grid_row: c.grid_row,
263
+ span: c.span,
264
+ }));
265
+ const newPreset = detectPreset(
266
+ cascadeCols,
267
+ section.settings.grid_columns
268
+ );
269
+ return {
270
+ ...section,
271
+ columns: updatedColumns,
272
+ settings: { ...section.settings, preset: newPreset },
273
+ };
274
+ });
275
+ return {
276
+ rows,
277
+ selectedColumnKey:
278
+ state.selectedColumnKey === columnKey ? null : state.selectedColumnKey,
279
+ isDirty: true,
280
+ };
281
+ });
282
+ },
283
+
284
+ resizeColumnV2: (sectionKey: string, columnKey: string, newSpan: number): void => {
285
+ get()._pushSnapshot();
286
+
287
+ set((state) => {
288
+ const path = findSectionPath(state.rows, sectionKey);
289
+ if (!path) return state;
290
+ const rows = updateSectionAtPath(state.rows, path, (section) => {
291
+ const updatedCascade = cascadeResizeColumn(
292
+ toCascadeColumns(section.columns),
293
+ columnKey,
294
+ newSpan,
295
+ section.settings.grid_columns
296
+ );
297
+ const updatedColumns = applyBlocksToColumns(
298
+ updatedCascade,
299
+ section.columns
300
+ );
301
+ const newPreset = detectPreset(
302
+ updatedCascade,
303
+ section.settings.grid_columns
304
+ );
305
+ return {
306
+ ...section,
307
+ columns: updatedColumns,
308
+ settings: { ...section.settings, preset: newPreset },
309
+ };
310
+ });
311
+ return { rows, isDirty: true };
312
+ });
313
+ },
314
+
315
+ resizeColumnV2Left: (sectionKey: string, columnKey: string, newGridColumn: number): void => {
316
+ const result = resizeColumnV2LeftInState(
317
+ get().rows,
318
+ sectionKey,
319
+ columnKey,
320
+ newGridColumn
321
+ );
322
+ if (!result) return; // Blocked by cascade or section not found
323
+
324
+ get()._pushSnapshot();
325
+ set({ rows: result.rows, isDirty: true });
326
+ },
327
+
328
+ moveColumnV2: (sectionKey: string, columnKey: string, targetRow: number, targetColumn: number): void => {
329
+ const result = moveColumnV2InState(
330
+ get().rows,
331
+ sectionKey,
332
+ columnKey,
333
+ targetRow,
334
+ targetColumn
335
+ );
336
+ if (!result) return;
337
+
338
+ get()._pushSnapshot();
339
+ set({ rows: result.rows, isDirty: true });
340
+ },
341
+
342
+ swapColumnV2: (sectionKey: string, draggedKey: string, targetKey: string): void => {
343
+ const result = swapColumnV2InState(
344
+ get().rows,
345
+ sectionKey,
346
+ draggedKey,
347
+ targetKey
348
+ );
349
+ if (!result) return;
350
+
351
+ get()._pushSnapshot();
352
+ set({ rows: result.rows, isDirty: true });
353
+ },
354
+
355
+ moveColumnToGapV2: (sectionKey: string, columnKey: string, targetRow: number, targetColumn: number, targetSpan: number): void => {
356
+ const result = moveColumnToGapV2InState(
357
+ get().rows,
358
+ sectionKey,
359
+ columnKey,
360
+ targetRow,
361
+ targetColumn,
362
+ targetSpan
363
+ );
364
+ if (!result) return;
365
+
366
+ get()._pushSnapshot();
367
+ set({ rows: result.rows, isDirty: true });
368
+ },
369
+
370
+ applyPresetV2: (sectionKey: string, preset: SectionV2Preset): void => {
371
+ if (preset === "custom") return;
372
+
373
+ get()._pushSnapshot();
374
+
375
+ set((state) => {
376
+ const path = findSectionPath(state.rows, sectionKey);
377
+ if (!path) return state;
378
+ const rows = updateSectionAtPath(state.rows, path, (section) => {
379
+ if (section.settings.preset === preset) return section;
380
+ const cascadeCols = columnsFromPreset(
381
+ preset as Exclude<typeof preset, "custom">,
382
+ section.settings.grid_columns,
383
+ generateKey
384
+ );
385
+ const newColumns = cascadeCols.map((c) => ({
386
+ ...c,
387
+ blocks: [] as ContentBlock[],
388
+ }));
389
+ return {
390
+ ...section,
391
+ columns: newColumns,
392
+ settings: { ...section.settings, preset },
393
+ };
394
+ });
395
+ return { rows, isDirty: true };
396
+ });
397
+ },
398
+
399
+ updateSectionV2Settings: (sectionKey: string, settings: Partial<SectionV2Settings>): void => {
400
+ set((state) => {
401
+ const path = findSectionPath(state.rows, sectionKey);
402
+ if (path) {
403
+ const rows = updateSectionAtPath(state.rows, path, (section) => ({
404
+ ...section,
405
+ settings: { ...section.settings, ...settings },
406
+ }));
407
+ return { rows, isDirty: true };
408
+ }
409
+ // Fallback: check if key matches a CustomSectionInstance (per-instance overrides)
410
+ const instance = state.rows.find(
411
+ (r) => r._key === sectionKey && isCustomSectionInstance(r)
412
+ );
413
+ if (instance) {
414
+ const inst = instance as CustomSectionInstance;
415
+ return {
416
+ rows: state.rows.map((r) =>
417
+ r._key === sectionKey
418
+ ? {
419
+ ...r,
420
+ settings_overrides: {
421
+ ...(inst.settings_overrides || {}),
422
+ ...settings,
423
+ },
424
+ }
425
+ : r
426
+ ),
427
+ isDirty: true,
428
+ };
429
+ }
430
+ return state;
431
+ });
432
+ },
433
+
434
+ updateSectionV2Responsive: (sectionKey: string, responsive: PageSectionV2["responsive"]): void => {
435
+ set((state) => {
436
+ const path = findSectionPath(state.rows, sectionKey);
437
+ if (path) {
438
+ const rows = updateSectionAtPath(state.rows, path, (section) => ({
439
+ ...section,
440
+ responsive,
441
+ }));
442
+ return { rows, isDirty: true };
443
+ }
444
+ // Fallback: check if key matches a CustomSectionInstance (per-instance responsive overrides)
445
+ const instance = state.rows.find(
446
+ (r) => r._key === sectionKey && isCustomSectionInstance(r)
447
+ );
448
+ if (instance) {
449
+ return {
450
+ rows: state.rows.map((r) =>
451
+ r._key === sectionKey
452
+ ? { ...r, responsive_overrides: responsive }
453
+ : r
454
+ ),
455
+ isDirty: true,
456
+ };
457
+ }
458
+ return state;
459
+ });
460
+ },
461
+
462
+ addBlockV2: (sectionKey: string, columnKey: string, blockType: BlockType, insertIndex?: number): void => {
463
+ get()._pushSnapshot();
464
+ const newBlock = createDefaultBlock(blockType);
465
+
466
+ set((state) => {
467
+ const path = findSectionPath(state.rows, sectionKey);
468
+ if (!path) return state;
469
+ const rows = updateSectionAtPath(state.rows, path, (section) => ({
470
+ ...section,
471
+ columns: section.columns.map((col) => {
472
+ if (col._key !== columnKey) return col;
473
+ if (
474
+ insertIndex !== undefined &&
475
+ insertIndex >= 0 &&
476
+ insertIndex < col.blocks.length
477
+ ) {
478
+ const blocks = [...col.blocks];
479
+ blocks.splice(insertIndex, 0, newBlock);
480
+ return { ...col, blocks };
481
+ }
482
+ return { ...col, blocks: [...col.blocks, newBlock] };
483
+ }),
484
+ }));
485
+ return { rows, selectedBlockKey: newBlock._key, isDirty: true };
486
+ });
487
+ },
488
+
489
+ selectColumnV2: (sectionKey: string | null, columnKey: string | null): void => {
490
+ set({
491
+ selectedRowKey: sectionKey,
492
+ selectedColumnKey: columnKey,
493
+ selectedBlockKey: null,
494
+ });
495
+ },
496
+
497
+ // Session 117: Update enter animation on a V2 column
498
+ updateColumnEnterAnimation: (sectionKey: string, colKey: string, config: EnterAnimationConfig | undefined): void => {
499
+ set((state) => {
500
+ const path = findSectionPath(state.rows, sectionKey);
501
+ if (!path) return state;
502
+ const rows = updateSectionAtPath(state.rows, path, (section) => ({
503
+ ...section,
504
+ columns: section.columns.map((col) =>
505
+ col._key === colKey ? { ...col, enter_animation: config } : col
506
+ ),
507
+ }));
508
+ return { rows, isDirty: true };
509
+ });
510
+ },
511
+
512
+ // ---- Custom Section Instance operations (Session 108) ----
513
+
514
+ addCustomSectionInstance: (
515
+ id: string,
516
+ slug: string,
517
+ title: string,
518
+ afterRowKey?: string | null
519
+ ) => {
520
+ get()._pushSnapshot();
521
+ const instance: CustomSectionInstance = {
522
+ _type: "customSectionInstance",
523
+ _key: generateKey(),
524
+ custom_section_id: id,
525
+ custom_section_slug: slug,
526
+ custom_section_title: title,
527
+ };
528
+
529
+ set((state) => {
530
+ const rows = [...state.rows];
531
+ if (afterRowKey) {
532
+ const idx = rows.findIndex((r) => r._key === afterRowKey);
533
+ if (idx !== -1) {
534
+ rows.splice(idx + 1, 0, instance);
535
+ } else {
536
+ rows.push(instance);
537
+ }
538
+ } else {
539
+ rows.push(instance);
540
+ }
541
+ return { rows, isDirty: true, selectedRowKey: instance._key };
542
+ });
543
+ },
544
+
545
+ detachCustomSectionInstance: (
546
+ instanceKey: string,
547
+ sectionData: PageSectionV2
548
+ ) => {
549
+ get()._pushSnapshot();
550
+ set((state) => ({
551
+ rows: state.rows.map((item) =>
552
+ item._key === instanceKey
553
+ ? { ...sectionData, _key: generateKey() }
554
+ : item
555
+ ),
556
+ isDirty: true,
557
+ }));
558
+ },
559
+
560
+ /**
561
+ * Update a cached custom section instance title (M1 audit fix — Session 110).
562
+ * Called when the card detects the remote title differs from the cached one.
563
+ * Does NOT push undo snapshot (cosmetic-only update).
564
+ */
565
+ updateCustomSectionInstanceTitle: (instanceKey: string, newTitle: string): void => {
566
+ set((state) => ({
567
+ rows: state.rows.map((item) =>
568
+ item._key === instanceKey && isCustomSectionInstance(item)
569
+ ? { ...item, custom_section_title: newTitle }
570
+ : item
571
+ ),
572
+ isDirty: true,
573
+ }));
574
+ },
575
+
576
+ /** Update per-instance section settings overrides (spacing, background, border, animation).
577
+ * Merges with existing overrides — pass only the fields to change. */
578
+ updateCustomSectionInstanceSettings: (
579
+ instanceKey: string,
580
+ updates: Partial<SectionV2Settings>
581
+ ) => {
582
+ get()._pushSnapshot();
583
+ set((state) => ({
584
+ rows: state.rows.map((item) =>
585
+ item._key === instanceKey && isCustomSectionInstance(item)
586
+ ? {
587
+ ...item,
588
+ settings_overrides: {
589
+ ...((item as CustomSectionInstance).settings_overrides || {}),
590
+ ...updates,
591
+ },
592
+ }
593
+ : item
594
+ ),
595
+ isDirty: true,
596
+ }));
597
+ },
598
+
599
+ /** Cache base settings from a fetched custom section.
600
+ * Called by CustomSectionInstanceCard and ReadOnlyCustomSection after data fetch.
601
+ * SortableRow reads this to merge base settings with per-instance overrides. */
602
+ cacheCustomSectionSettings: (sectionId: string, settings: SectionV2Settings): void => {
603
+ set((state) => ({
604
+ _customSectionCache: { ...state._customSectionCache, [sectionId]: settings },
605
+ }));
606
+ },
607
+
608
+ // ---- Parallax Group operations (Session 123) ----
609
+
610
+ addParallaxGroup: (afterRowKey?: string | null): void => {
611
+ get()._pushSnapshot();
612
+ const result = addParallaxGroupInState(get().rows, afterRowKey);
613
+ set({ rows: result.rows, isDirty: true, selectedRowKey: result.newGroupKey });
614
+ },
615
+
616
+ addParallaxSlide: (groupKey: string): void => {
617
+ get()._pushSnapshot();
618
+ set((state) => ({
619
+ rows: state.rows.map((item) => {
620
+ if (item._key !== groupKey || !isParallaxGroup(item)) return item;
621
+ const group = item as ParallaxGroup;
622
+ const newSlide: ParallaxSlideV2 = createDefaultParallaxSlide();
623
+ return { ...group, slides: [...group.slides, newSlide] } as ContentItem;
624
+ }),
625
+ isDirty: true,
626
+ }));
627
+ },
628
+
629
+ removeParallaxSlide: (groupKey: string, slideKey: string): void => {
630
+ get()._pushSnapshot();
631
+ set((state) => {
632
+ let changed = false;
633
+ const rows = state.rows.map((item) => {
634
+ if (item._key !== groupKey || !isParallaxGroup(item)) return item;
635
+ const group = item as ParallaxGroup;
636
+ // Enforce minimum 1 slide
637
+ if (group.slides.length <= 1) return item;
638
+ changed = true;
639
+ return {
640
+ ...group,
641
+ slides: group.slides.filter((s) => s._key !== slideKey),
642
+ } as ContentItem;
643
+ });
644
+ return changed ? { rows, isDirty: true } : {};
645
+ });
646
+ },
647
+
648
+ moveParallaxSlide: (
649
+ groupKey: string,
650
+ slideKey: string,
651
+ direction: "up" | "down"
652
+ ) => {
653
+ get()._pushSnapshot();
654
+ set((state) => {
655
+ let changed = false;
656
+ const rows = state.rows.map((item) => {
657
+ if (item._key !== groupKey || !isParallaxGroup(item)) return item;
658
+ const group = item as ParallaxGroup;
659
+ const slides = [...group.slides];
660
+ const idx = slides.findIndex((s) => s._key === slideKey);
661
+ if (idx === -1) return item;
662
+ const targetIdx = direction === "up" ? idx - 1 : idx + 1;
663
+ if (targetIdx < 0 || targetIdx >= slides.length) return item;
664
+ [slides[idx], slides[targetIdx]] = [slides[targetIdx], slides[idx]];
665
+ changed = true;
666
+ return { ...group, slides } as ContentItem;
667
+ });
668
+ return changed ? { rows, isDirty: true } : {};
669
+ });
670
+ },
671
+
672
+ updateParallaxSlideBackground: (
673
+ groupKey: string,
674
+ slideKey: string,
675
+ fields: Partial<ParallaxSlideV2>
676
+ ) => {
677
+ get()._pushSnapshot();
678
+ set((state) => ({
679
+ rows: state.rows.map((item) => {
680
+ if (item._key !== groupKey || !isParallaxGroup(item)) return item;
681
+ const group = item as ParallaxGroup;
682
+ return {
683
+ ...group,
684
+ slides: group.slides.map((s) =>
685
+ s._key === slideKey ? { ...s, ...fields } : s
686
+ ),
687
+ } as ContentItem;
688
+ }),
689
+ isDirty: true,
690
+ }));
691
+ },
692
+
693
+ updateParallaxGroupSettings: (
694
+ groupKey: string,
695
+ fields: Partial<
696
+ Pick<ParallaxGroup, "transition_effect" | "snap_enabled" | "parallax_intensity">
697
+ >
698
+ ) => {
699
+ get()._pushSnapshot();
700
+ set((state) => ({
701
+ rows: state.rows.map((item) => {
702
+ if (item._key !== groupKey || !isParallaxGroup(item)) return item;
703
+ return { ...item, ...fields } as ContentItem;
704
+ }),
705
+ isDirty: true,
706
+ }));
707
+ },
708
+ };
709
+ }