@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
@@ -7,16 +7,29 @@ import type { PageSectionV2, CoverSection, SectionColumn } from "../../lib/sanit
7
7
  import { isPageSectionV2, isCoverSection } from "../../lib/sanity/types";
8
8
  import { BUILDER_BLUE } from "../../lib/builder/constants";
9
9
 
10
+ /** Color used when the current drop target is invalid (cross-section
11
+ * swap, or a target that is not V2/Cover). Red 500 with compatible
12
+ * translucent tints for the overlay chrome. */
13
+ const INVALID_RED = "#ef4444";
14
+ const INVALID_RED_RGB = "239, 68, 68";
15
+
10
16
  interface ColumnDragOverlayProps {
11
17
  sectionKey: string;
12
18
  columnKey: string;
13
19
  position: { x: number; y: number };
20
+ /**
21
+ * Whether the current drop target under the cursor is a valid drop.
22
+ * - true (or no target): render in blue (default).
23
+ * - false: render in red to signal "drop here won't execute".
24
+ */
25
+ isValidDrop?: boolean;
14
26
  }
15
27
 
16
28
  const ColumnDragOverlay = memo(function ColumnDragOverlay({
17
29
  sectionKey,
18
30
  columnKey,
19
31
  position,
32
+ isValidDrop = true,
20
33
  }: ColumnDragOverlayProps) {
21
34
  const rows = useBuilderStore((s) => s.rows);
22
35
  const item = rows.find((r) => r._key === sectionKey);
@@ -44,6 +57,10 @@ const ColumnDragOverlay = memo(function ColumnDragOverlay({
44
57
 
45
58
  const blockCount = (col.blocks || []).length;
46
59
 
60
+ // Pick accent based on drop validity.
61
+ const accentColor = isValidDrop ? BUILDER_BLUE : INVALID_RED;
62
+ const accentRgb = isValidDrop ? "71, 148, 226" : INVALID_RED_RGB;
63
+
47
64
  const overlay = (
48
65
  <div
49
66
  style={{
@@ -53,18 +70,20 @@ const ColumnDragOverlay = memo(function ColumnDragOverlay({
53
70
  transform: "translate(-50%, -50%)",
54
71
  pointerEvents: "none",
55
72
  zIndex: 99999,
73
+ transition: "filter 120ms ease",
56
74
  }}
57
75
  >
58
76
  <div
59
77
  style={{
60
78
  width: 180,
61
79
  minHeight: 80,
62
- background: "rgba(71, 148, 226, 0.08)",
80
+ background: `rgba(${accentRgb}, 0.08)`,
63
81
  backdropFilter: "blur(8px)",
64
82
  opacity: 0.85,
65
83
  borderRadius: 8,
66
- border: `2px solid ${BUILDER_BLUE}`,
67
- boxShadow: "0 8px 32px rgba(71, 148, 226, 0.3)",
84
+ border: `2px solid ${accentColor}`,
85
+ boxShadow: `0 8px 32px rgba(${accentRgb}, 0.3)`,
86
+ transition: "border-color 120ms ease, background 120ms ease, box-shadow 120ms ease",
68
87
  }}
69
88
  >
70
89
  {/* Column header badge */}
@@ -74,17 +93,25 @@ const ColumnDragOverlay = memo(function ColumnDragOverlay({
74
93
  alignItems: "center",
75
94
  gap: 8,
76
95
  padding: "8px 12px",
77
- borderBottom: "1px solid rgba(71, 148, 226, 0.2)",
96
+ borderBottom: `1px solid rgba(${accentRgb}, 0.2)`,
78
97
  }}
79
98
  >
80
- <svg width="10" height="10" viewBox="0 0 10 10" fill={BUILDER_BLUE}>
81
- <circle cx="3" cy="3" r="1" />
82
- <circle cx="7" cy="3" r="1" />
83
- <circle cx="3" cy="7" r="1" />
84
- <circle cx="7" cy="7" r="1" />
85
- </svg>
99
+ {isValidDrop ? (
100
+ <svg width="10" height="10" viewBox="0 0 10 10" fill={accentColor}>
101
+ <circle cx="3" cy="3" r="1" />
102
+ <circle cx="7" cy="3" r="1" />
103
+ <circle cx="3" cy="7" r="1" />
104
+ <circle cx="7" cy="7" r="1" />
105
+ </svg>
106
+ ) : (
107
+ // "Blocked" icon when invalid — slashed circle
108
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="none">
109
+ <circle cx="8" cy="8" r="6.5" stroke={accentColor} strokeWidth="1.5" />
110
+ <line x1="3.5" y1="12.5" x2="12.5" y2="3.5" stroke={accentColor} strokeWidth="1.5" />
111
+ </svg>
112
+ )}
86
113
  <span style={{ fontSize: 12, color: "white", fontWeight: 500 }}>
87
- Column {col.span}/{gridColumns}
114
+ {isValidDrop ? `Column ${col.span}/${gridColumns}` : "Cannot drop here"}
88
115
  </span>
89
116
  </div>
90
117
  {/* Block count indicators */}
@@ -1,8 +1,8 @@
1
1
  "use client";
2
2
 
3
- import { useMemo, useState } from "react";
3
+ import { useCallback, useMemo, useState } from "react";
4
4
  import { useBuilderStore } from "../../lib/builder/store";
5
- import type { CoverSection, CoverRow, PageSectionV2 } from "../../lib/sanity/types";
5
+ import type { CoverSection, CoverRow, PageSectionV2, ColumnOverride } from "../../lib/sanity/types";
6
6
  import type { DeviceViewport } from "../../lib/builder/types";
7
7
  import SectionV2Canvas from "./SectionV2Canvas";
8
8
  import CoverRowResizeHandle from "./CoverRowResizeHandle";
@@ -84,9 +84,37 @@ export default function CoverSectionCanvas({
84
84
  const virtualSectionsPerRow = useMemo(() => {
85
85
  return effectiveRows.map((row, rowIndex) => {
86
86
  const rowNumber = rowIndex + 1;
87
+ const rowColumnKeys = new Set(
88
+ section.columns.filter((c) => c.grid_row === rowNumber).map((c) => c._key),
89
+ );
87
90
  const rowColumns = section.columns
88
91
  .filter((c) => c.grid_row === rowNumber)
89
92
  .map((c) => ({ ...c, grid_row: 1 }));
93
+
94
+ // Project the Cover's responsive overrides into this row's virtual section.
95
+ // Only keep column overrides whose `_key` belongs to this row, and remap their
96
+ // `grid_row` from the Cover's actual row number back to 1 (the virtual row).
97
+ // Without this, SectionV2Canvas calls `getEffectiveColumnsV2` against the virtual
98
+ // section and never sees the overrides — tablet/phone view falls back to the
99
+ // desktop layout even when overrides exist.
100
+ const projectOverridesForRow = (
101
+ overrides: ColumnOverride[] | undefined,
102
+ ): ColumnOverride[] | undefined => {
103
+ if (!overrides) return undefined;
104
+ const filtered = overrides
105
+ .filter((o) => rowColumnKeys.has(o._key))
106
+ .map((o) =>
107
+ o.grid_row !== undefined ? { ...o, grid_row: 1 } : o,
108
+ );
109
+ return filtered.length ? filtered : undefined;
110
+ };
111
+
112
+ const virtualResponsive: PageSectionV2["responsive"] = {};
113
+ const tabletCols = projectOverridesForRow(section.responsive?.tablet?.columns);
114
+ const phoneCols = projectOverridesForRow(section.responsive?.phone?.columns);
115
+ if (tabletCols) virtualResponsive.tablet = { columns: tabletCols };
116
+ if (phoneCols) virtualResponsive.phone = { columns: phoneCols };
117
+
90
118
  const virtualSection: PageSectionV2 = {
91
119
  _type: "pageSectionV2",
92
120
  _key: section._key,
@@ -98,11 +126,70 @@ export default function CoverSectionCanvas({
98
126
  col_gap: section.settings.col_gap ?? 20,
99
127
  row_gap: section.settings.row_gap ?? 20,
100
128
  },
129
+ ...(Object.keys(virtualResponsive).length > 0
130
+ ? { responsive: virtualResponsive }
131
+ : {}),
101
132
  };
102
133
  return { row, rowNumber, virtualSection };
103
134
  });
104
135
  }, [effectiveRows, section]);
105
136
 
137
+ // Custom responsive updater for virtual per-row sections. Takes the responsive
138
+ // object that SectionV2Canvas built (with `grid_row: 1` for this row's columns
139
+ // and keys limited to this row) and merges it back into the Cover's flat
140
+ // column override list: remaps `grid_row: 1` back to `rowNumber`, preserves
141
+ // overrides for other rows, and preserves `cover_rows` + `settings` at the
142
+ // responsive level which are invisible to SectionV2Canvas.
143
+ const handleVirtualRowResponsiveUpdate = useCallback(
144
+ (rowNumber: number) =>
145
+ (_sectionKey: string, incoming: PageSectionV2["responsive"]): void => {
146
+ const rowColumnKeys = new Set(
147
+ section.columns.filter((c) => c.grid_row === rowNumber).map((c) => c._key),
148
+ );
149
+
150
+ const merged: CoverSection["responsive"] = {};
151
+
152
+ (["tablet", "phone"] as const).forEach((vp) => {
153
+ const existingVp = section.responsive?.[vp];
154
+ const incomingVp = incoming?.[vp];
155
+
156
+ // Columns for OTHER rows from the Cover's existing responsive stay as-is.
157
+ const otherRowOverrides = (existingVp?.columns || []).filter(
158
+ (o) => !rowColumnKeys.has(o._key),
159
+ );
160
+
161
+ // Columns for THIS row come from the incoming virtual-section responsive,
162
+ // with grid_row remapped 1 → rowNumber.
163
+ const thisRowOverrides = (incomingVp?.columns || []).map((o) =>
164
+ o.grid_row !== undefined ? { ...o, grid_row: rowNumber } : o,
165
+ );
166
+
167
+ const mergedCols = [...otherRowOverrides, ...thisRowOverrides];
168
+
169
+ const vpOverride: NonNullable<CoverSection["responsive"]>[typeof vp] = {};
170
+ if (mergedCols.length > 0) vpOverride.columns = mergedCols;
171
+ // Preserve cover_rows + settings that the virtual section never sees.
172
+ if (existingVp?.cover_rows) vpOverride.cover_rows = existingVp.cover_rows;
173
+ if (existingVp?.settings) vpOverride.settings = existingVp.settings;
174
+
175
+ if (Object.keys(vpOverride).length > 0) {
176
+ merged[vp] = vpOverride;
177
+ }
178
+ });
179
+
180
+ const finalResponsive = Object.keys(merged).length > 0 ? merged : undefined;
181
+ // CoverSection and PageSectionV2 share the same `updateSectionV2Responsive`
182
+ // store action (it writes to any section by _key); the runtime shapes align
183
+ // on `{ tablet?, phone? }` — the extra `cover_rows` field on CoverSection
184
+ // is carried through without touching the V2-shaped write path.
185
+ store.updateSectionV2Responsive(
186
+ section._key,
187
+ finalResponsive as PageSectionV2["responsive"],
188
+ );
189
+ },
190
+ [section, store],
191
+ );
192
+
106
193
  return (
107
194
  <div
108
195
  className="relative"
@@ -219,6 +306,7 @@ export default function CoverSectionCanvas({
219
306
  onAddBlockTarget={onAddBlockTarget}
220
307
  fillHeight
221
308
  gridRowOffset={rowNumber - 1}
309
+ onUpdateResponsive={handleVirtualRowResponsiveUpdate(rowNumber)}
222
310
  />
223
311
  </div>
224
312
  ) : (
@@ -30,6 +30,11 @@ interface InsertionLinesProps {
30
30
  colGap: number;
31
31
  sectionKey: string;
32
32
  dropTarget: DropTarget | null;
33
+ /** Offset added to grid_row in emitted `data-insert-row` attributes —
34
+ * required when this SectionV2Canvas is a virtual per-row view inside
35
+ * a CoverSection (rows are remapped to 1 for rendering; the real
36
+ * grid_row must be recovered for the DnD hit-test). Default 0. */
37
+ gridRowOffset?: number;
33
38
  }
34
39
 
35
40
  export const InsertionLines = memo(function InsertionLines({
@@ -40,6 +45,7 @@ export const InsertionLines = memo(function InsertionLines({
40
45
  colGap,
41
46
  sectionKey,
42
47
  dropTarget,
48
+ gridRowOffset = 0,
43
49
  }: InsertionLinesProps) {
44
50
  // Compute insertion points between adjacent columns (no gap between them)
45
51
  // Exclude the currently dragged column from consideration
@@ -124,7 +130,9 @@ export const InsertionLines = memo(function InsertionLines({
124
130
  <div
125
131
  data-col-v2-insert=""
126
132
  data-section-key={sectionKey}
127
- data-insert-row={pt.row}
133
+ // Absolute grid_row (including Cover row offset) — see
134
+ // SectionV2Canvas's data-gap-row comment for rationale.
135
+ data-insert-row={pt.row + gridRowOffset}
128
136
  data-insert-col={pt.gridColumn}
129
137
  data-insert-left-key={pt.leftColKey}
130
138
  data-insert-right-key={pt.rightColKey}
@@ -27,6 +27,13 @@ interface SectionV2CanvasProps {
27
27
  fillHeight?: boolean;
28
28
  /** Offset added to grid_row when adding columns via gaps (used by cover sections where columns are normalized to grid_row 1) */
29
29
  gridRowOffset?: number;
30
+ /**
31
+ * Optional override for the responsive-update action. When provided, used instead of the
32
+ * store's `updateSectionV2Responsive`. Used by CoverSectionCanvas to intercept writes from
33
+ * virtual per-row sections and merge them back into the Cover's flat column list (remapping
34
+ * `grid_row: 1` back to the row's real number and preserving overrides for other rows).
35
+ */
36
+ onUpdateResponsive?: (sectionKey: string, responsive: PageSectionV2["responsive"]) => void;
30
37
  }
31
38
 
32
39
  export default function SectionV2Canvas({
@@ -34,6 +41,7 @@ export default function SectionV2Canvas({
34
41
  onAddBlockTarget,
35
42
  fillHeight,
36
43
  gridRowOffset = 0,
44
+ onUpdateResponsive,
37
45
  }: SectionV2CanvasProps) {
38
46
  const previewMode = useBuilderStore((s) => s.previewMode);
39
47
  const canvasZoom = useBuilderStore((s) => s.canvasZoom);
@@ -44,7 +52,8 @@ export default function SectionV2Canvas({
44
52
  const deleteColumnV2 = useBuilderStore((s) => s.deleteColumnV2);
45
53
  const resizeColumnV2 = useBuilderStore((s) => s.resizeColumnV2);
46
54
  const resizeColumnV2Left = useBuilderStore((s) => s.resizeColumnV2Left);
47
- const updateSectionV2Responsive = useBuilderStore((s) => s.updateSectionV2Responsive);
55
+ const storeUpdateSectionV2Responsive = useBuilderStore((s) => s.updateSectionV2Responsive);
56
+ const updateSectionV2Responsive = onUpdateResponsive ?? storeUpdateSectionV2Responsive;
48
57
  const selectColumnV2 = useBuilderStore((s) => s.selectColumnV2);
49
58
  const selectBlock = useBuilderStore((s) => s.selectBlock);
50
59
  const deleteBlock = useBuilderStore((s) => s.deleteBlock);
@@ -69,8 +78,13 @@ export default function SectionV2Canvas({
69
78
  startDrag,
70
79
  } = useColumnDragContext();
71
80
 
72
- // Only show insertion lines if the drag originates from THIS section
73
- const showInsertionLines = isColDragActive && draggingSectionKey === section._key;
81
+ // Show insertion lines + highlighted gaps:
82
+ // - While dragging, in the section the drag ORIGINATED from (source feedback)
83
+ // - While dragging, in ANY section the cursor is currently over (target feedback
84
+ // for cross-section drops — the user needs a visible drop zone in the target).
85
+ const showInsertionLines =
86
+ isColDragActive &&
87
+ (draggingSectionKey === section._key || isSectionHovered);
74
88
 
75
89
  // When drag ends (isColDragActive becomes false), reset hover state so it
76
90
  // doesn't stay stuck true if the pointer ended outside the grid.
@@ -148,7 +162,10 @@ export default function SectionV2Canvas({
148
162
  style={{
149
163
  display: "grid",
150
164
  gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
151
- ...(fillHeight ? { gridTemplateRows: "1fr" } : {}),
165
+ // `minmax(0, 1fr)` lets the row shrink below intrinsic content min-size
166
+ // (gaps' minHeight, empty-column wrappers), which is required in Cover
167
+ // sections where the row has a strict proportional height.
168
+ ...(fillHeight ? { gridTemplateRows: "minmax(0, 1fr)" } : {}),
152
169
  columnGap: `${colGap}px`,
153
170
  rowGap: `${rowGap}px`,
154
171
  position: "relative",
@@ -232,6 +249,7 @@ export default function SectionV2Canvas({
232
249
  }
233
250
  onResizeRight={handleResizeRight}
234
251
  onResizeLeft={handleResizeLeft}
252
+ fillHeight={fillHeight}
235
253
  >
236
254
  {(col.blocks || []).map((block, blockIdx) => (
237
255
  <SortableBlock
@@ -267,7 +285,11 @@ export default function SectionV2Canvas({
267
285
  key={`gap-${gap.grid_row}-${gap.grid_column}`}
268
286
  data-col-v2-gap=""
269
287
  data-section-key={section._key}
270
- data-gap-row={gap.grid_row}
288
+ // Absolute grid_row (including Cover row offset) — used by the
289
+ // DnD hit-test. Without the offset, Cover drops in row 2/3 would
290
+ // collapse to row 1. The `onClick` below applies the same offset
291
+ // to keep "+ Add Column" consistent with drag drops.
292
+ data-gap-row={gap.grid_row + gridRowOffset}
271
293
  data-gap-col={gap.grid_column}
272
294
  data-gap-span={gap.span}
273
295
  onClick={(e) => {
@@ -277,7 +299,10 @@ export default function SectionV2Canvas({
277
299
  style={{
278
300
  gridColumn: `${gap.grid_column} / span ${gap.span}`,
279
301
  gridRow: gap.grid_row,
280
- minHeight: 70,
302
+ // In fillHeight (Cover), the row has a strict proportional
303
+ // height — don't let the gap force a minimum that would push
304
+ // content past the row boundary.
305
+ minHeight: fillHeight ? 0 : 70,
281
306
  }}
282
307
  className={`rounded-lg border-2 border-dashed text-xs font-medium transition-all flex items-center justify-center cursor-pointer ${
283
308
  isGapTarget
@@ -304,6 +329,7 @@ export default function SectionV2Canvas({
304
329
  colGap={colGap}
305
330
  sectionKey={section._key}
306
331
  dropTarget={dropTarget}
332
+ gridRowOffset={gridRowOffset}
307
333
  />
308
334
  )}
309
335
  </div>
@@ -131,6 +131,9 @@ interface SectionV2ColumnProps {
131
131
  onAddBlock: (insertIndex?: number) => void;
132
132
  onResizeRight: (columnKey: string, startX: number, startSpan: number, containerEl: HTMLElement) => void;
133
133
  onResizeLeft: (columnKey: string, startX: number, startGridCol: number, startSpan: number, containerEl: HTMLElement) => void;
134
+ /** When true, the column lives in a Cover row with strict proportional height —
135
+ * empty-state minHeight is relaxed so the column doesn't overflow its row. */
136
+ fillHeight?: boolean;
134
137
  children: ReactNode;
135
138
  }
136
139
 
@@ -152,6 +155,7 @@ export default function SectionV2Column({
152
155
  onAddBlock,
153
156
  onResizeRight,
154
157
  onResizeLeft,
158
+ fillHeight = false,
155
159
  children,
156
160
  }: SectionV2ColumnProps) {
157
161
  const previewMode = useBuilderStore((s) => s.previewMode);
@@ -453,7 +457,7 @@ export default function SectionV2Column({
453
457
  /* Empty column: show + Add Block (flex-1 stretches in fillHeight cover sections) */
454
458
  <div
455
459
  className="relative flex items-center justify-center flex-1"
456
- style={{ minHeight: 80, padding: "16px 12px" }}
460
+ style={{ minHeight: fillHeight ? 0 : 80, padding: "16px 12px" }}
457
461
  >
458
462
  <button
459
463
  onClick={handleAddBlockEmpty}
@@ -4,7 +4,7 @@ import { useState, useMemo, useCallback, useRef, useEffect, Fragment } from "rea
4
4
  import type { RegisteredAsset } from "../../../lib/sanity/types";
5
5
  import { VirtualAssetGrid } from "../VirtualAssetGrid";
6
6
  import type { UploadingFile } from "./types";
7
- import { formatFileSize, isImageType, isVideoType, isFontType, buildFolderTree } from "./helpers";
7
+ import { formatFileSize, isImageType, isVideoType, isAudioType, isFontType, buildFolderTree } from "./helpers";
8
8
  import { FolderTreeItem } from "./FolderTreeItem";
9
9
  import { VideoThumbnail } from "./VideoThumbnail";
10
10
  import { FileLightbox } from "./FileLightbox";
@@ -34,7 +34,7 @@ export function R2BrowserContent({
34
34
  setSelectedAsset: (a: RegisteredAsset | null) => void;
35
35
  onRetry: () => void;
36
36
  onDoubleClick?: (asset: RegisteredAsset) => void;
37
- filterType?: "image" | "video" | "all";
37
+ filterType?: "image" | "video" | "audio" | "all";
38
38
  multiSelect?: boolean;
39
39
  selectedAssets?: RegisteredAsset[];
40
40
  setSelectedAssets?: (assets: RegisteredAsset[]) => void;
@@ -99,6 +99,7 @@ export function R2BrowserContent({
99
99
 
100
100
  if (filterType === "image") filtered = filtered.filter((a) => isImageType(a.extension));
101
101
  else if (filterType === "video") filtered = filtered.filter((a) => isVideoType(a.extension));
102
+ else if (filterType === "audio") filtered = filtered.filter((a) => isAudioType(a.extension));
102
103
 
103
104
  if (searchQuery.trim()) {
104
105
  const q = searchQuery.trim().normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
@@ -106,8 +107,12 @@ export function R2BrowserContent({
106
107
  (a) => {
107
108
  const nameNorm = a.filename.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
108
109
  const pathNorm = a.path.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
109
- return a.filename !== ".folder" && (nameNorm.includes(q) || pathNorm.includes(q)) &&
110
- (filterType === "all" || (filterType === "image" ? isImageType(a.extension) : isVideoType(a.extension)));
110
+ const matchesType =
111
+ filterType === "all" ||
112
+ (filterType === "image" && isImageType(a.extension)) ||
113
+ (filterType === "video" && isVideoType(a.extension)) ||
114
+ (filterType === "audio" && isAudioType(a.extension));
115
+ return a.filename !== ".folder" && (nameNorm.includes(q) || pathNorm.includes(q)) && matchesType;
111
116
  }
112
117
  );
113
118
  }
@@ -185,6 +190,18 @@ export function R2BrowserContent({
185
190
  );
186
191
  }
187
192
 
193
+ if (isAudioType(asset.extension)) {
194
+ return (
195
+ <div className="w-full h-full flex items-center justify-center bg-neutral-50">
196
+ <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="text-neutral-300">
197
+ <path d="M9 18V5l12-2v13" />
198
+ <circle cx="6" cy="18" r="3" />
199
+ <circle cx="18" cy="16" r="3" />
200
+ </svg>
201
+ </div>
202
+ );
203
+ }
204
+
188
205
  return (
189
206
  <div className="w-full h-full flex items-center justify-center bg-neutral-50">
190
207
  <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-neutral-300">
@@ -208,7 +225,7 @@ export function R2BrowserContent({
208
225
  ref={ops.fileInputRef}
209
226
  type="file"
210
227
  multiple
211
- accept="image/jpeg,image/png,image/webp,image/gif,image/svg+xml,video/mp4,video/webm,video/quicktime"
228
+ accept="image/jpeg,image/png,image/webp,image/gif,image/svg+xml,video/mp4,video/webm,video/quicktime,audio/mpeg,audio/wav,audio/ogg,audio/mp4,audio/aac,audio/flac"
212
229
  className="hidden"
213
230
  onChange={dnd.handleFileInputChange}
214
231
  />
@@ -227,7 +244,7 @@ export function R2BrowserContent({
227
244
  <p className="text-sm font-medium text-[#076bff]">
228
245
  Drop files or folders here{currentFolder ? ` to ${currentFolder}` : ""}
229
246
  </p>
230
- <p className="text-xs text-neutral-500">Supported formats: JPG, PNG, WebP, GIF, SVG, MP4, WebM, MOV</p>
247
+ <p className="text-xs text-neutral-500">Supported formats: JPG, PNG, WebP, GIF, SVG, MP4, WebM, MOV, MP3, WAV, OGG, M4A, AAC, FLAC</p>
231
248
  <p className="text-xs text-neutral-400">Maximum file size: 500 MB</p>
232
249
  </div>
233
250
  </div>
@@ -62,6 +62,10 @@ export function isVideoType(ext: string): boolean {
62
62
  return ["mp4", "webm", "mov"].includes(ext);
63
63
  }
64
64
 
65
+ export function isAudioType(ext: string): boolean {
66
+ return ["mp3", "wav", "ogg", "m4a", "aac", "flac"].includes(ext);
67
+ }
68
+
65
69
  export function isFontType(ext: string): boolean {
66
70
  return ["otf", "ttf", "woff", "woff2"].includes(ext);
67
71
  }
@@ -17,6 +17,7 @@ export const MAX_UPLOAD_SIZE = 500 * 1024 * 1024; // 500 MB per file
17
17
  export const ALLOWED_EXTENSIONS = new Set([
18
18
  "jpg", "jpeg", "png", "webp", "gif", "svg",
19
19
  "mp4", "webm", "mov",
20
+ "mp3", "wav", "ogg", "m4a", "aac", "flac",
20
21
  ]);
21
22
 
22
23
  // ============================================
@@ -27,7 +28,7 @@ export interface AssetBrowserProps {
27
28
  open: boolean;
28
29
  onSelect: (path: string) => void;
29
30
  onClose: () => void;
30
- filterType?: "image" | "video" | "all";
31
+ filterType?: "image" | "video" | "audio" | "all";
31
32
  /** Enable multi-select mode: user can pick multiple assets at once */
32
33
  multiSelect?: boolean;
33
34
  /** Called with all selected paths when multiSelect is true */
@@ -20,6 +20,8 @@ import {
20
20
  VideoBlockCardIcon,
21
21
  SpacerBlockCardIcon,
22
22
  ButtonBlockCardIcon,
23
+ BeforeAfterBlockCardIcon,
24
+ AudioBlockCardIcon,
23
25
  } from "./BlockCardIcons";
24
26
  import {
25
27
  CoverSectionCardIcon,
@@ -39,6 +41,8 @@ export const BLOCK_GRADIENTS: Record<string, string> = {
39
41
  videoBlock: "linear-gradient(135deg, #ffb8d4 0%, #ffc8a8 50%, #ffe0b8 100%)",
40
42
  spacerBlock: "linear-gradient(135deg, #d8d8e8 0%, #e8e8f0 50%, #f0f0f8 100%)",
41
43
  buttonBlock: "linear-gradient(135deg, #ffb8e0 0%, #b8ffe8 50%, #a8ffd8 100%)",
44
+ beforeAfterBlock: "linear-gradient(135deg, #d8d8e8 0%, #a8c8ff 50%, #b8ffe0 100%)",
45
+ audioBlock: "linear-gradient(135deg, #a8c8ff 0%, #d8c0ff 50%, #e0b8ff 100%)",
42
46
  projectGridBlock: "linear-gradient(135deg, #ffd4a8 0%, #ffe8b8 50%, #fff0c8 100%)",
43
47
  coverSection: "linear-gradient(135deg, #b2f5ea 0%, #81e6d9 50%, #5eead4 100%)",
44
48
  parallaxGroup: "linear-gradient(135deg, #c8a8ff 0%, #d8b8ff 50%, #e8d0ff 100%)",
@@ -83,6 +87,12 @@ export function SpacerBlockIcon({ size = 28 }: { size?: number }) {
83
87
  export function ButtonBlockIcon({ size = 28 }: { size?: number }) {
84
88
  return <span style={scaleToHeight(size)}><ButtonBlockCardIcon /></span>;
85
89
  }
90
+ export function BeforeAfterBlockIcon({ size = 28 }: { size?: number }) {
91
+ return <span style={scaleToHeight(size)}><BeforeAfterBlockCardIcon /></span>;
92
+ }
93
+ export function AudioBlockIcon({ size = 28 }: { size?: number }) {
94
+ return <span style={scaleToHeight(size)}><AudioBlockCardIcon /></span>;
95
+ }
86
96
 
87
97
  // ── Non-block context icons (compact wrappers of the section card icons) ──
88
98
 
@@ -162,6 +172,8 @@ export const BLOCK_ICON_COMPONENTS: Record<string, React.FC<{ size?: number }>>
162
172
  videoBlock: VideoBlockIcon,
163
173
  spacerBlock: SpacerBlockIcon,
164
174
  buttonBlock: ButtonBlockIcon,
175
+ beforeAfterBlock: BeforeAfterBlockIcon,
176
+ audioBlock: AudioBlockIcon,
165
177
  projectGridBlock: ProjectGridBlockIcon,
166
178
  projectCarouselBlock: ProjectCarouselBlockIcon,
167
179
  parallaxGroup: ParallaxGroupIcon,