@morphika/andami 0.4.2 → 0.5.1

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 (36) hide show
  1. package/README.md +151 -36
  2. package/app/admin/layout.tsx +145 -152
  3. package/components/blocks/AudioBlockRenderer.tsx +286 -0
  4. package/components/blocks/BeforeAfterBlockRenderer.tsx +274 -0
  5. package/components/builder/BlockCardIcons.tsx +89 -0
  6. package/components/builder/BlockTypePicker.tsx +2 -0
  7. package/components/builder/ColumnDragContext.tsx +5 -0
  8. package/components/builder/ColumnDragOverlay.tsx +38 -11
  9. package/components/builder/CoverSectionCanvas.tsx +90 -2
  10. package/components/builder/InsertionLines.tsx +9 -1
  11. package/components/builder/SectionV2Canvas.tsx +32 -6
  12. package/components/builder/SectionV2Column.tsx +5 -1
  13. package/components/builder/asset-browser/R2BrowserContent.tsx +23 -6
  14. package/components/builder/asset-browser/helpers.ts +4 -0
  15. package/components/builder/asset-browser/types.ts +2 -1
  16. package/components/builder/blockStyles.tsx +12 -0
  17. package/components/builder/editors/AudioBlockEditor.tsx +242 -0
  18. package/components/builder/editors/BeforeAfterBlockEditor.tsx +360 -0
  19. package/components/builder/editors/shared.tsx +1 -1
  20. package/components/builder/hooks/useColumnDrag.ts +206 -132
  21. package/components/builder/live-preview/LiveAudioPreview.tsx +120 -0
  22. package/components/builder/live-preview/LiveBeforeAfterPreview.tsx +176 -0
  23. package/lib/animation/enter-types.ts +2 -0
  24. package/lib/animation/hover-effect-types.ts +2 -0
  25. package/lib/builder/block-registrations.ts +83 -1
  26. package/lib/builder/store-helpers.ts +302 -1
  27. package/lib/builder/store-sections.ts +60 -0
  28. package/lib/builder/types-slices.ts +27 -0
  29. package/lib/builder/types.ts +2 -0
  30. package/lib/sanity/types.ts +75 -0
  31. package/lib/version.ts +1 -1
  32. package/package.json +1 -1
  33. package/sanity/schemas/blocks/audioBlock.ts +69 -0
  34. package/sanity/schemas/blocks/beforeAfterBlock.ts +121 -0
  35. package/sanity/schemas/blocks/index.ts +3 -1
  36. package/sanity/schemas/index.ts +7 -1
@@ -73,6 +73,8 @@ export const BLOCK_ENTER_PRESETS: Record<BlockType, readonly EnterPreset[]> = {
73
73
  imageGridBlock: ["fade", "scale", "slide-up"],
74
74
  videoBlock: ["fade", "slide-up"],
75
75
  buttonBlock: ["fade", "slide-up", "scale"],
76
+ beforeAfterBlock: ["fade", "slide-up", "scale"],
77
+ audioBlock: ["fade", "slide-up", "scale"],
76
78
  spacerBlock: [], // invisible — no animation
77
79
  projectGridBlock: [], // uses card_entrance system
78
80
  projectCarouselBlock: [], // uses card_entrance system
@@ -66,6 +66,8 @@ export const BLOCK_HOVER_PRESETS: Record<BlockType, readonly HoverPreset[]> = {
66
66
  imageGridBlock: ["tilt-3d"],
67
67
  videoBlock: [], // video has play/pause interaction
68
68
  buttonBlock: ["scale-up", "lift", "border-glow"],
69
+ beforeAfterBlock: [], // slider handles its own drag interaction
70
+ audioBlock: [], // audio has play/pause interaction
69
71
  spacerBlock: [], // invisible
70
72
  projectGridBlock: ["scale-up", "lift"], // per-card effect
71
73
  projectCarouselBlock: [], // uses per-card hover_effect field directly
@@ -2,7 +2,7 @@
2
2
 
3
3
  /**
4
4
  * Block registrations — fills the registry defined in `./block-registry.ts`
5
- * with the 8 built-in block types shipped by the framework.
5
+ * with the 10 built-in block types shipped by the framework.
6
6
  *
7
7
  * Importing this module has a side effect: every `registerBlockType()`
8
8
  * call below runs synchronously and populates the module-level registry.
@@ -26,6 +26,8 @@ import type {
26
26
  VideoBlock,
27
27
  SpacerBlock,
28
28
  ButtonBlock,
29
+ BeforeAfterBlock,
30
+ AudioBlock,
29
31
  ProjectGridBlock,
30
32
  ProjectCarouselBlock,
31
33
  } from "../sanity/types";
@@ -39,6 +41,8 @@ import {
39
41
  videoBlock,
40
42
  spacerBlock,
41
43
  buttonBlock,
44
+ beforeAfterBlock,
45
+ audioBlock,
42
46
  projectGridBlock,
43
47
  projectCarouselBlock,
44
48
  } from "../../sanity/schemas/blocks";
@@ -51,6 +55,8 @@ import ImageGridBlockRenderer from "../../components/blocks/ImageGridBlockRender
51
55
  import VideoBlockRenderer from "../../components/blocks/VideoBlockRenderer";
52
56
  import SpacerBlockRenderer from "../../components/blocks/SpacerBlockRenderer";
53
57
  import ButtonBlockRenderer from "../../components/blocks/ButtonBlockRenderer";
58
+ import BeforeAfterBlockRenderer from "../../components/blocks/BeforeAfterBlockRenderer";
59
+ import AudioBlockRenderer from "../../components/blocks/AudioBlockRenderer";
54
60
  import ProjectGridBlockRenderer from "../../components/blocks/ProjectGridBlockRenderer";
55
61
  import ProjectCarouselBlockRenderer from "../../components/blocks/ProjectCarouselBlockRenderer";
56
62
 
@@ -62,6 +68,8 @@ import LiveImageGridPreview from "../../components/builder/live-preview/LiveImag
62
68
  import LiveVideoPreview from "../../components/builder/live-preview/LiveVideoPreview";
63
69
  import LiveSpacerPreview from "../../components/builder/live-preview/LiveSpacerPreview";
64
70
  import LiveButtonPreview from "../../components/builder/live-preview/LiveButtonPreview";
71
+ import LiveBeforeAfterPreview from "../../components/builder/live-preview/LiveBeforeAfterPreview";
72
+ import LiveAudioPreview from "../../components/builder/live-preview/LiveAudioPreview";
65
73
  import LiveProjectGridPreview from "../../components/builder/live-preview/LiveProjectGridPreview";
66
74
  import LiveProjectCarouselPreview from "../../components/builder/live-preview/LiveProjectCarouselPreview";
67
75
 
@@ -73,6 +81,8 @@ import ImageGridBlockEditor from "../../components/builder/editors/ImageGridBloc
73
81
  import VideoBlockEditor from "../../components/builder/editors/VideoBlockEditor";
74
82
  import SpacerBlockEditor from "../../components/builder/editors/SpacerBlockEditor";
75
83
  import ButtonBlockEditor from "../../components/builder/editors/ButtonBlockEditor";
84
+ import BeforeAfterBlockEditor from "../../components/builder/editors/BeforeAfterBlockEditor";
85
+ import AudioBlockEditor from "../../components/builder/editors/AudioBlockEditor";
76
86
  import ProjectGridEditor from "../../components/builder/editors/ProjectGridEditor";
77
87
  import ProjectCarouselBlockEditor from "../../components/builder/editors/ProjectCarouselBlockEditor";
78
88
 
@@ -85,6 +95,8 @@ import {
85
95
  VideoBlockCardIcon,
86
96
  SpacerBlockCardIcon,
87
97
  ButtonBlockCardIcon,
98
+ BeforeAfterBlockCardIcon,
99
+ AudioBlockCardIcon,
88
100
  } from "../../components/builder/BlockCardIcons";
89
101
  import {
90
102
  ProjectGridCardIcon,
@@ -100,6 +112,8 @@ import {
100
112
  VideoBlockIcon,
101
113
  SpacerBlockIcon,
102
114
  ButtonBlockIcon,
115
+ BeforeAfterBlockIcon,
116
+ AudioBlockIcon,
103
117
  ProjectGridBlockIcon,
104
118
  ProjectCarouselBlockIcon,
105
119
  } from "../../components/builder/blockStyles";
@@ -267,6 +281,74 @@ registerBlockType<ButtonBlock>({
267
281
  hoverPresets: ["scale-up", "lift", "border-glow"],
268
282
  });
269
283
 
284
+ registerBlockType<BeforeAfterBlock>({
285
+ type: "beforeAfterBlock",
286
+ label: "Before / After",
287
+ description: "Drag-slider comparison between two images or videos",
288
+ category: "content",
289
+ iconGlyph: "◫",
290
+ schema: beforeAfterBlock,
291
+ defaultFactory: (key) => ({
292
+ _type: "beforeAfterBlock",
293
+ _key: key,
294
+ before_media_type: "image",
295
+ before_asset_path: "",
296
+ before_alt: "",
297
+ after_media_type: "image",
298
+ after_asset_path: "",
299
+ after_alt: "",
300
+ orientation: "horizontal",
301
+ initial_position: 50,
302
+ handle_color: "#FFFFFF",
303
+ width: "full",
304
+ aspect_ratio: "16:9",
305
+ video_autoplay: true,
306
+ video_loop: true,
307
+ video_muted: true,
308
+ border_radius: "",
309
+ shadow: false,
310
+ }),
311
+ renderer: BeforeAfterBlockRenderer as React.ComponentType<{ block: BeforeAfterBlock }>,
312
+ livePreview: LiveBeforeAfterPreview as unknown as React.ComponentType<{ block: BeforeAfterBlock; viewport?: import("./types").DeviceViewport; editable?: boolean }>,
313
+ editor: BeforeAfterBlockEditor as React.ComponentType<{ block: BeforeAfterBlock }>,
314
+ cardIcon: BeforeAfterBlockCardIcon,
315
+ compactIcon: BeforeAfterBlockIcon,
316
+ enterPresets: ["fade", "slide-up", "scale"],
317
+ hoverPresets: [],
318
+ });
319
+
320
+ registerBlockType<AudioBlock>({
321
+ type: "audioBlock",
322
+ label: "Audio",
323
+ description: "Minimal audio player with cover art and metadata",
324
+ category: "content",
325
+ iconGlyph: "♪",
326
+ schema: audioBlock,
327
+ defaultFactory: (key) => ({
328
+ _type: "audioBlock",
329
+ _key: key,
330
+ asset_path: "",
331
+ alt: "",
332
+ title: "",
333
+ artist: "",
334
+ cover_path: "",
335
+ accent_color: "#4794E2",
336
+ width: "contained",
337
+ border_radius: "",
338
+ shadow: false,
339
+ autoplay: false,
340
+ loop: false,
341
+ muted: false,
342
+ }),
343
+ renderer: AudioBlockRenderer as React.ComponentType<{ block: AudioBlock }>,
344
+ livePreview: LiveAudioPreview as unknown as React.ComponentType<{ block: AudioBlock; viewport?: import("./types").DeviceViewport; editable?: boolean }>,
345
+ editor: AudioBlockEditor as React.ComponentType<{ block: AudioBlock }>,
346
+ cardIcon: AudioBlockCardIcon,
347
+ compactIcon: AudioBlockIcon,
348
+ enterPresets: ["fade", "slide-up", "scale"],
349
+ hoverPresets: [],
350
+ });
351
+
270
352
  // ── Section-level blocks ──
271
353
 
272
354
  registerBlockType<ProjectGridBlock>({
@@ -20,7 +20,12 @@ import type {
20
20
  } from "../../lib/sanity/types";
21
21
  import { isPageSectionV2, isParallaxGroup, isCoverSection } from "../../lib/sanity/types";
22
22
  import { columnsFromPreset, detectPreset } from "./cascade";
23
- import { resizeColumnLeft as cascadeResizeLeft, moveColumn as cascadeMoveColumn, type ResizeLeftResult } from "./cascade";
23
+ import {
24
+ resizeColumnLeft as cascadeResizeLeft,
25
+ moveColumn as cascadeMoveColumn,
26
+ addColumn as cascadeAddColumn,
27
+ type ResizeLeftResult,
28
+ } from "./cascade";
24
29
  import { applyBlocksToColumns, toCascadeColumns, type CascadeColumn } from "./cascade-helpers";
25
30
  import { generateKey } from "./utils";
26
31
  import { createDefaultParallaxSlide } from "./defaults";
@@ -625,3 +630,299 @@ export function addParallaxGroupInState(
625
630
 
626
631
  return { rows: newRows, newGroupKey: newGroup._key };
627
632
  }
633
+
634
+ // ============================================
635
+ // moveColumnBetweenSectionsInState (Session 183 — cross-section column DnD)
636
+ // ============================================
637
+
638
+ export interface MoveColumnBetweenSectionsResult {
639
+ rows: ContentItem[];
640
+ /** Final span after clamping to target grid. */
641
+ finalSpan: number;
642
+ /** Final grid_column after clamping. */
643
+ finalGridColumn: number;
644
+ }
645
+
646
+ /**
647
+ * Move a column from one columnar section to another (V2 ↔ Cover).
648
+ *
649
+ * Blocks travel with the column. Responsive overrides for the moved
650
+ * column are STRIPPED from the source section — responsive overrides
651
+ * are per-section `_key` lookups and don't carry semantic meaning in
652
+ * the new home. Tablet/phone layouts for the moved column revert to
653
+ * desktop until the user re-adds overrides in the target.
654
+ *
655
+ * Span and grid_column are clamped to target's grid_columns.
656
+ *
657
+ * Returns null if:
658
+ * - source or target key doesn't resolve to a columnar section
659
+ * - source and target are the same section (caller should use
660
+ * moveColumnV2InState instead)
661
+ * - the column key doesn't exist in the source
662
+ */
663
+ export function moveColumnBetweenSectionsInState(
664
+ rows: ContentItem[],
665
+ sourceSectionKey: string,
666
+ columnKey: string,
667
+ targetSectionKey: string,
668
+ targetRow: number,
669
+ targetColumn: number,
670
+ targetSpan: number,
671
+ ): MoveColumnBetweenSectionsResult | null {
672
+ if (sourceSectionKey === targetSectionKey) return null;
673
+
674
+ const sourcePath = findSectionPath(rows, sourceSectionKey);
675
+ const targetPath = findSectionPath(rows, targetSectionKey);
676
+ if (!sourcePath || !targetPath) return null;
677
+
678
+ // Read the source column (full SectionColumn with blocks)
679
+ const sourceVirtual = getSectionFromPath(rows, sourcePath);
680
+ if (!sourceVirtual) return null;
681
+ const movedColumn = sourceVirtual.columns.find((c) => c._key === columnKey);
682
+ if (!movedColumn) return null;
683
+
684
+ // Read target for grid_columns
685
+ const targetVirtual = getSectionFromPath(rows, targetPath);
686
+ if (!targetVirtual) return null;
687
+ const targetGridCols = targetVirtual.settings.grid_columns || 12;
688
+
689
+ // Clamp span + grid column to target grid
690
+ const clampedSpan = Math.max(1, Math.min(targetSpan, targetGridCols));
691
+ const clampedCol = Math.max(
692
+ 1,
693
+ Math.min(targetColumn, targetGridCols - clampedSpan + 1),
694
+ );
695
+
696
+ // --- Step 1: Remove column from source, strip its responsive overrides ---
697
+ let newRows = updateSectionAtPath(rows, sourcePath, (section) => {
698
+ const filteredColumns = section.columns.filter((c) => c._key !== columnKey);
699
+
700
+ // Strip responsive overrides referencing the moved column
701
+ let newResponsive = section.responsive;
702
+ if (newResponsive) {
703
+ const cleanResponsive: PageSectionV2["responsive"] = {};
704
+ for (const vp of ["tablet", "phone"] as const) {
705
+ const vpData = newResponsive[vp];
706
+ if (!vpData) continue;
707
+ const newVpData: NonNullable<PageSectionV2["responsive"]>[typeof vp] = {
708
+ ...vpData,
709
+ };
710
+ if (newVpData.columns) {
711
+ const filtered = newVpData.columns.filter((o) => o._key !== columnKey);
712
+ newVpData.columns = filtered.length > 0 ? filtered : undefined;
713
+ }
714
+ // Keep viewport entry only if it still has something
715
+ if (newVpData.columns || newVpData.settings) {
716
+ cleanResponsive[vp] = newVpData;
717
+ }
718
+ }
719
+ newResponsive =
720
+ Object.keys(cleanResponsive).length > 0 ? cleanResponsive : undefined;
721
+ }
722
+
723
+ const cascadeCols = toCascadeColumns(filteredColumns);
724
+ const newPreset = detectPreset(cascadeCols, section.settings.grid_columns);
725
+
726
+ return {
727
+ ...section,
728
+ columns: filteredColumns,
729
+ responsive: newResponsive,
730
+ settings: { ...section.settings, preset: newPreset },
731
+ };
732
+ });
733
+
734
+ // --- Step 2: Insert the column into target with cascade ---
735
+ newRows = updateSectionAtPath(newRows, targetPath, (section) => {
736
+ // Augment the source list so `applyBlocksToColumns` can find the moved
737
+ // column's blocks by key. The position fields here are overwritten by
738
+ // cascade; only the blocks matter for the lookup.
739
+ const augmentedSource: SectionColumn[] = [
740
+ ...section.columns,
741
+ {
742
+ ...movedColumn,
743
+ grid_row: targetRow,
744
+ grid_column: clampedCol,
745
+ span: clampedSpan,
746
+ },
747
+ ];
748
+
749
+ const cascadeResult = cascadeAddColumn(
750
+ toCascadeColumns(section.columns),
751
+ targetRow,
752
+ clampedCol,
753
+ clampedSpan,
754
+ columnKey,
755
+ section.settings.grid_columns,
756
+ );
757
+
758
+ const finalColumns = applyBlocksToColumns(cascadeResult, augmentedSource);
759
+ const newPreset = detectPreset(cascadeResult, section.settings.grid_columns);
760
+
761
+ return {
762
+ ...section,
763
+ columns: finalColumns,
764
+ settings: { ...section.settings, preset: newPreset },
765
+ };
766
+ });
767
+
768
+ return {
769
+ rows: newRows,
770
+ finalSpan: clampedSpan,
771
+ finalGridColumn: clampedCol,
772
+ };
773
+ }
774
+
775
+ // ============================================
776
+ // swapColumnsBetweenSectionsInState (Session 183 — cross-section column swap)
777
+ // ============================================
778
+
779
+ export interface SwapColumnsBetweenSectionsResult {
780
+ rows: ContentItem[];
781
+ }
782
+
783
+ /**
784
+ * Swap two columns across different columnar sections (V2 ↔ Cover).
785
+ *
786
+ * Semantically identical to the intra-section `swapColumnV2InState`:
787
+ * each column adopts the other's position/row/span. The difference is
788
+ * that the columns live in DIFFERENT sections, so each has to travel.
789
+ * Blocks go with their owner column.
790
+ *
791
+ * Clamping: when the two sections have different `grid_columns`, each
792
+ * column's new span and grid_column are clamped to fit the destination
793
+ * grid. Clamping is defensive — if both sections share 12 columns (the
794
+ * common case), no clamping happens.
795
+ *
796
+ * Responsive: overrides for BOTH swapped columns are stripped from both
797
+ * sections' responsive tables. Overrides live per-section by `_key`
798
+ * lookup; transplanting either direction would leave orphaned entries.
799
+ *
800
+ * Returns null if:
801
+ * - source and target are the same section
802
+ * - either key doesn't resolve to a columnar section
803
+ * - either column key doesn't exist in its section
804
+ */
805
+ export function swapColumnsBetweenSectionsInState(
806
+ rows: ContentItem[],
807
+ sourceSectionKey: string,
808
+ sourceColumnKey: string,
809
+ targetSectionKey: string,
810
+ targetColumnKey: string,
811
+ ): SwapColumnsBetweenSectionsResult | null {
812
+ if (sourceSectionKey === targetSectionKey) return null;
813
+
814
+ const sourcePath = findSectionPath(rows, sourceSectionKey);
815
+ const targetPath = findSectionPath(rows, targetSectionKey);
816
+ if (!sourcePath || !targetPath) return null;
817
+
818
+ const sourceVirtual = getSectionFromPath(rows, sourcePath);
819
+ const targetVirtual = getSectionFromPath(rows, targetPath);
820
+ if (!sourceVirtual || !targetVirtual) return null;
821
+
822
+ const sourceCol = sourceVirtual.columns.find((c) => c._key === sourceColumnKey);
823
+ const targetCol = targetVirtual.columns.find((c) => c._key === targetColumnKey);
824
+ if (!sourceCol || !targetCol) return null;
825
+
826
+ const sourceGridCols = sourceVirtual.settings.grid_columns || 12;
827
+ const targetGridCols = targetVirtual.settings.grid_columns || 12;
828
+
829
+ // Clamp source column's new dimensions (adopting target's position) to target grid
830
+ const newSourceSpan = Math.max(1, Math.min(targetCol.span, targetGridCols));
831
+ const newSourceGridCol = Math.max(
832
+ 1,
833
+ Math.min(targetCol.grid_column, targetGridCols - newSourceSpan + 1),
834
+ );
835
+
836
+ // Clamp target column's new dimensions (adopting source's position) to source grid
837
+ const newTargetSpan = Math.max(1, Math.min(sourceCol.span, sourceGridCols));
838
+ const newTargetGridCol = Math.max(
839
+ 1,
840
+ Math.min(sourceCol.grid_column, sourceGridCols - newTargetSpan + 1),
841
+ );
842
+
843
+ // --- Helper: strip responsive overrides for a given column key ---
844
+ const stripResponsiveForKey = (
845
+ responsive: PageSectionV2["responsive"],
846
+ columnKey: string,
847
+ ): PageSectionV2["responsive"] => {
848
+ if (!responsive) return responsive;
849
+ const cleanResponsive: PageSectionV2["responsive"] = {};
850
+ for (const vp of ["tablet", "phone"] as const) {
851
+ const vpData = responsive[vp];
852
+ if (!vpData) continue;
853
+ const newVpData: NonNullable<PageSectionV2["responsive"]>[typeof vp] = {
854
+ ...vpData,
855
+ };
856
+ if (newVpData.columns) {
857
+ const filtered = newVpData.columns.filter((o) => o._key !== columnKey);
858
+ newVpData.columns = filtered.length > 0 ? filtered : undefined;
859
+ }
860
+ if (newVpData.columns || newVpData.settings) {
861
+ cleanResponsive[vp] = newVpData;
862
+ }
863
+ }
864
+ return Object.keys(cleanResponsive).length > 0 ? cleanResponsive : undefined;
865
+ };
866
+
867
+ // --- Step 1: Replace source column with target column (transplanted) ---
868
+ // Also strip sourceColumnKey's responsive overrides.
869
+ let newRows = updateSectionAtPath(rows, sourcePath, (section) => {
870
+ // Build the transplanted column with target's blocks, adopting
871
+ // source's OLD position/row/span (clamped to source grid).
872
+ const transplanted: SectionColumn = {
873
+ ...targetCol,
874
+ grid_row: sourceCol.grid_row,
875
+ grid_column: newTargetGridCol,
876
+ span: newTargetSpan,
877
+ };
878
+ const updatedColumns = section.columns.map((c) =>
879
+ c._key === sourceColumnKey ? transplanted : c,
880
+ );
881
+
882
+ const newResponsive = stripResponsiveForKey(
883
+ section.responsive,
884
+ sourceColumnKey,
885
+ );
886
+
887
+ const cascadeCols = toCascadeColumns(updatedColumns);
888
+ const newPreset = detectPreset(cascadeCols, section.settings.grid_columns);
889
+
890
+ return {
891
+ ...section,
892
+ columns: updatedColumns,
893
+ responsive: newResponsive,
894
+ settings: { ...section.settings, preset: newPreset },
895
+ };
896
+ });
897
+
898
+ // --- Step 2: Replace target column with source column (transplanted) ---
899
+ // Also strip targetColumnKey's responsive overrides.
900
+ newRows = updateSectionAtPath(newRows, targetPath, (section) => {
901
+ const transplanted: SectionColumn = {
902
+ ...sourceCol,
903
+ grid_row: targetCol.grid_row,
904
+ grid_column: newSourceGridCol,
905
+ span: newSourceSpan,
906
+ };
907
+ const updatedColumns = section.columns.map((c) =>
908
+ c._key === targetColumnKey ? transplanted : c,
909
+ );
910
+
911
+ const newResponsive = stripResponsiveForKey(
912
+ section.responsive,
913
+ targetColumnKey,
914
+ );
915
+
916
+ const cascadeCols = toCascadeColumns(updatedColumns);
917
+ const newPreset = detectPreset(cascadeCols, section.settings.grid_columns);
918
+
919
+ return {
920
+ ...section,
921
+ columns: updatedColumns,
922
+ responsive: newResponsive,
923
+ settings: { ...section.settings, preset: newPreset },
924
+ };
925
+ });
926
+
927
+ return { rows: newRows };
928
+ }
@@ -38,6 +38,8 @@ import {
38
38
  moveColumnV2InState,
39
39
  swapColumnV2InState,
40
40
  moveColumnToGapV2InState,
41
+ moveColumnBetweenSectionsInState,
42
+ swapColumnsBetweenSectionsInState,
41
43
  } from "./store-helpers";
42
44
 
43
45
  type StoreSet = (
@@ -339,6 +341,64 @@ export function createSectionActions(set: StoreSet, get: StoreGet): SectionSlice
339
341
  set({ rows: result.rows, isDirty: true });
340
342
  },
341
343
 
344
+ moveColumnBetweenSections: (
345
+ sourceSectionKey: string,
346
+ columnKey: string,
347
+ targetSectionKey: string,
348
+ targetRow: number,
349
+ targetColumn: number,
350
+ targetSpan: number,
351
+ ): void => {
352
+ const result = moveColumnBetweenSectionsInState(
353
+ get().rows,
354
+ sourceSectionKey,
355
+ columnKey,
356
+ targetSectionKey,
357
+ targetRow,
358
+ targetColumn,
359
+ targetSpan,
360
+ );
361
+ if (!result) return;
362
+
363
+ get()._pushSnapshot();
364
+ // Clear any selection referencing the moved column in its old section —
365
+ // the column key remains but the "selected section" context has changed.
366
+ set({
367
+ rows: result.rows,
368
+ isDirty: true,
369
+ selectedColumnKey: columnKey,
370
+ selectedRowKey: targetSectionKey,
371
+ selectedBlockKey: null,
372
+ });
373
+ },
374
+
375
+ swapColumnsBetweenSections: (
376
+ sourceSectionKey: string,
377
+ sourceColumnKey: string,
378
+ targetSectionKey: string,
379
+ targetColumnKey: string,
380
+ ): void => {
381
+ const result = swapColumnsBetweenSectionsInState(
382
+ get().rows,
383
+ sourceSectionKey,
384
+ sourceColumnKey,
385
+ targetSectionKey,
386
+ targetColumnKey,
387
+ );
388
+ if (!result) return;
389
+
390
+ get()._pushSnapshot();
391
+ // After swap: the dragged column now lives in the target section.
392
+ // Select it there (match mental model: "I just put this column here").
393
+ set({
394
+ rows: result.rows,
395
+ isDirty: true,
396
+ selectedColumnKey: sourceColumnKey,
397
+ selectedRowKey: targetSectionKey,
398
+ selectedBlockKey: null,
399
+ });
400
+ },
401
+
342
402
  applyPresetV2: (sectionKey: string, preset: SectionV2Preset): void => {
343
403
  if (preset === "custom") return;
344
404
 
@@ -124,6 +124,33 @@ export interface SectionSliceActions {
124
124
  targetColumn: number,
125
125
  targetSpan: number,
126
126
  ) => void;
127
+ /**
128
+ * Move a column from one columnar section (V2 or Cover) to another.
129
+ * Blocks travel with the column. Responsive overrides for the moved
130
+ * column are stripped from the source section. Span/grid_column are
131
+ * clamped to the target's grid_columns. Returns the final landed span
132
+ * so the caller can adjust UI feedback (e.g. show clamped value).
133
+ */
134
+ moveColumnBetweenSections: (
135
+ sourceSectionKey: string,
136
+ columnKey: string,
137
+ targetSectionKey: string,
138
+ targetRow: number,
139
+ targetColumn: number,
140
+ targetSpan: number,
141
+ ) => void;
142
+ /**
143
+ * Swap two columns across different columnar sections (V2 ↔ Cover).
144
+ * Each column adopts the other's position/row/span. Blocks travel
145
+ * with the column. Responsive overrides for both swapped columns
146
+ * are stripped (overrides are per-section by `_key`).
147
+ */
148
+ swapColumnsBetweenSections: (
149
+ sourceSectionKey: string,
150
+ sourceColumnKey: string,
151
+ targetSectionKey: string,
152
+ targetColumnKey: string,
153
+ ) => void;
127
154
  applyPresetV2: (sectionKey: string, preset: SectionV2Preset) => void;
128
155
  updateSectionV2Settings: (sectionKey: string, settings: Partial<SectionV2Settings>) => void;
129
156
  updateSectionV2Responsive: (
@@ -51,6 +51,8 @@ export const BLOCK_TYPE_REGISTRY: BlockTypeInfo[] = [
51
51
  { type: "videoBlock", label: "Video", description: "Vimeo, YouTube, or MP4", group: "generic", icon: "▶", category: "content" },
52
52
  { type: "spacerBlock", label: "Spacer", description: "Vertical spacing", group: "generic", icon: "↕", category: "content" },
53
53
  { type: "buttonBlock", label: "Button", description: "Call-to-action button", group: "generic", icon: "▣", category: "content" },
54
+ { type: "beforeAfterBlock", label: "Before / After", description: "Drag-slider comparison between two images or videos", group: "generic", icon: "◫", category: "content" },
55
+ { type: "audioBlock", label: "Audio", description: "Minimal audio player with cover art and metadata", group: "generic", icon: "♪", category: "content" },
54
56
  ];
55
57
 
56
58
  /**
@@ -210,6 +210,62 @@ export interface ButtonBlock {
210
210
  responsive?: ResponsiveOverrides<ButtonBlock>;
211
211
  }
212
212
 
213
+ export interface BeforeAfterBlock {
214
+ _type: "beforeAfterBlock";
215
+ _key: string;
216
+ // Before side
217
+ before_media_type?: "image" | "video";
218
+ before_asset_path: string;
219
+ before_alt?: string;
220
+ // After side
221
+ after_media_type?: "image" | "video";
222
+ after_asset_path: string;
223
+ after_alt?: string;
224
+ // Slider
225
+ orientation?: "horizontal" | "vertical";
226
+ initial_position?: number; // 0–100
227
+ handle_color?: string; // hex
228
+ // Layout
229
+ width?: "full" | "contained" | "small" | "fill";
230
+ aspect_ratio?: "auto" | "16:9" | "4:3" | "1:1" | "21:9";
231
+ // Video playback (applies when either side is video)
232
+ video_autoplay?: boolean;
233
+ video_loop?: boolean;
234
+ video_muted?: boolean;
235
+ // Appearance
236
+ border_radius?: string;
237
+ shadow?: boolean;
238
+ enter_animation?: import("../../lib/animation/enter-types").EnterAnimationConfig;
239
+ hover_effect?: import("../../lib/animation/hover-effect-types").HoverEffectConfig;
240
+ layout?: BlockLayout;
241
+ responsive?: ResponsiveOverrides<BeforeAfterBlock>;
242
+ }
243
+
244
+ export interface AudioBlock {
245
+ _type: "audioBlock";
246
+ _key: string;
247
+ // Source
248
+ asset_path: string;
249
+ alt?: string;
250
+ // Metadata
251
+ title?: string;
252
+ artist?: string;
253
+ cover_path?: string;
254
+ // Appearance
255
+ accent_color?: string;
256
+ width?: "full" | "contained" | "small" | "fill";
257
+ border_radius?: string;
258
+ shadow?: boolean;
259
+ // Playback
260
+ autoplay?: boolean;
261
+ loop?: boolean;
262
+ muted?: boolean;
263
+ enter_animation?: import("../../lib/animation/enter-types").EnterAnimationConfig;
264
+ hover_effect?: import("../../lib/animation/hover-effect-types").HoverEffectConfig;
265
+ layout?: BlockLayout;
266
+ responsive?: ResponsiveOverrides<AudioBlock>;
267
+ }
268
+
213
269
  // ============================================
214
270
  // Project Grid Block v2 (template-only, Session 105)
215
271
  // ============================================
@@ -596,6 +652,23 @@ export function isCoverSection(item: ContentItem): item is CoverSection {
596
652
  return (item as CoverSection)._type === "coverSection";
597
653
  }
598
654
 
655
+ /**
656
+ * Type guard: check if a content item supports column drag-and-drop as a
657
+ * source or target — i.e. it has a top-level `columns` array with the same
658
+ * V2 grid semantics. Used by cross-section column DnD (Session 183).
659
+ *
660
+ * Eligible: PageSectionV2, CoverSection
661
+ * NOT eligible:
662
+ * - ParallaxGroup (columns live per-slide, cross-slide DnD not supported)
663
+ * - CustomSectionInstance (references an external document, mutating it
664
+ * from the page would break the reference contract)
665
+ */
666
+ export function isColumnarSection(
667
+ item: ContentItem,
668
+ ): item is PageSectionV2 | CoverSection {
669
+ return isPageSectionV2(item) || isCoverSection(item);
670
+ }
671
+
599
672
  // ============================================
600
673
  // Shared references
601
674
  // ============================================
@@ -618,6 +691,8 @@ export type ContentBlock =
618
691
  | VideoBlock
619
692
  | SpacerBlock
620
693
  | ButtonBlock
694
+ | BeforeAfterBlock
695
+ | AudioBlock
621
696
  | ProjectGridBlock
622
697
  | ProjectCarouselBlock;
623
698