@morphika/andami 0.5.0 → 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 (29) 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/CoverSectionCanvas.tsx +90 -2
  8. package/components/builder/SectionV2Canvas.tsx +19 -3
  9. package/components/builder/SectionV2Column.tsx +5 -1
  10. package/components/builder/asset-browser/R2BrowserContent.tsx +23 -6
  11. package/components/builder/asset-browser/helpers.ts +4 -0
  12. package/components/builder/asset-browser/types.ts +2 -1
  13. package/components/builder/blockStyles.tsx +12 -0
  14. package/components/builder/editors/AudioBlockEditor.tsx +242 -0
  15. package/components/builder/editors/BeforeAfterBlockEditor.tsx +360 -0
  16. package/components/builder/editors/shared.tsx +1 -1
  17. package/components/builder/live-preview/LiveAudioPreview.tsx +120 -0
  18. package/components/builder/live-preview/LiveBeforeAfterPreview.tsx +176 -0
  19. package/lib/animation/enter-types.ts +2 -0
  20. package/lib/animation/hover-effect-types.ts +2 -0
  21. package/lib/builder/block-registrations.ts +83 -1
  22. package/lib/builder/types.ts +2 -0
  23. package/lib/sanity/types.ts +58 -0
  24. package/lib/version.ts +1 -1
  25. package/package.json +1 -1
  26. package/sanity/schemas/blocks/audioBlock.ts +69 -0
  27. package/sanity/schemas/blocks/beforeAfterBlock.ts +121 -0
  28. package/sanity/schemas/blocks/index.ts +3 -1
  29. package/sanity/schemas/index.ts +7 -1
@@ -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
  ) : (
@@ -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);
@@ -153,7 +162,10 @@ export default function SectionV2Canvas({
153
162
  style={{
154
163
  display: "grid",
155
164
  gridTemplateColumns: `repeat(${gridColumns}, 1fr)`,
156
- ...(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)" } : {}),
157
169
  columnGap: `${colGap}px`,
158
170
  rowGap: `${rowGap}px`,
159
171
  position: "relative",
@@ -237,6 +249,7 @@ export default function SectionV2Canvas({
237
249
  }
238
250
  onResizeRight={handleResizeRight}
239
251
  onResizeLeft={handleResizeLeft}
252
+ fillHeight={fillHeight}
240
253
  >
241
254
  {(col.blocks || []).map((block, blockIdx) => (
242
255
  <SortableBlock
@@ -286,7 +299,10 @@ export default function SectionV2Canvas({
286
299
  style={{
287
300
  gridColumn: `${gap.grid_column} / span ${gap.span}`,
288
301
  gridRow: gap.grid_row,
289
- 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,
290
306
  }}
291
307
  className={`rounded-lg border-2 border-dashed text-xs font-medium transition-all flex items-center justify-center cursor-pointer ${
292
308
  isGapTarget
@@ -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,
@@ -0,0 +1,242 @@
1
+ "use client";
2
+
3
+ import { useBuilderStore } from "../../../lib/builder/store";
4
+ import { getEffectiveValue, setResponsiveOverride } from "../../../lib/builder/responsive";
5
+ import type { AudioBlock, ContentBlock } from "../../../lib/sanity/types";
6
+ import ColorSwatchPicker, { usePaletteSwatches } from "../ColorSwatchPicker";
7
+ import { resolveColorHex } from "../../../lib/color-utils";
8
+ import {
9
+ SourceIcon,
10
+ LayoutIcon,
11
+ AppearanceIcon,
12
+ PlaybackIcon,
13
+ OptionsIcon,
14
+ } from "./section-icons";
15
+ import {
16
+ SettingsField,
17
+ SettingsSection,
18
+ StyledCheckbox,
19
+ AssetPathInput,
20
+ ViewportBadge,
21
+ ResponsiveField,
22
+ useActiveViewport,
23
+ INPUT_CLASS,
24
+ } from "./shared";
25
+
26
+ interface Props {
27
+ block: AudioBlock;
28
+ }
29
+
30
+ export default function AudioBlockEditor({ block }: Props) {
31
+ const store = useBuilderStore();
32
+ const viewport = useActiveViewport();
33
+ const paletteSwatches = usePaletteSwatches();
34
+
35
+ const snapshotOnFocus = () => store._pushSnapshot();
36
+
37
+ const updateResponsive = (property: string, value: unknown) => {
38
+ if (viewport === "desktop") {
39
+ store.updateBlock(block._key, { [property]: value } as Partial<ContentBlock>);
40
+ } else {
41
+ const overrides = setResponsiveOverride(block as ContentBlock, viewport, property, value);
42
+ store.updateBlock(block._key, overrides as Partial<ContentBlock>);
43
+ }
44
+ };
45
+
46
+ const resetOverride = (property: string) => {
47
+ const overrides = setResponsiveOverride(block as ContentBlock, viewport, property, undefined);
48
+ store.updateBlock(block._key, overrides as Partial<ContentBlock>);
49
+ };
50
+
51
+ const update = (updates: Partial<AudioBlock>) => {
52
+ store.updateBlock(block._key, updates as Partial<ContentBlock>);
53
+ };
54
+
55
+ const updateDebounced = (updates: Partial<AudioBlock>) => {
56
+ store.updateBlockDebounced(block._key, updates as Partial<ContentBlock>);
57
+ };
58
+
59
+ const effectiveWidth = getEffectiveValue<string>(
60
+ block as ContentBlock, viewport, "width", block.width || "contained"
61
+ );
62
+
63
+ return (
64
+ <>
65
+ <ViewportBadge />
66
+
67
+ {/* ── Source ── */}
68
+ <SettingsSection title="Audio" defaultOpen icon={<SourceIcon />}>
69
+ <SettingsField label="Audio File" hint="mp3, wav, ogg, m4a, aac, flac">
70
+ <AssetPathInput
71
+ value={block.asset_path || ""}
72
+ onFocus={snapshotOnFocus}
73
+ onChange={(v) => updateDebounced({ asset_path: v })}
74
+ placeholder="projects/slug/track.mp3"
75
+ filterType="audio"
76
+ />
77
+ </SettingsField>
78
+
79
+ <SettingsField label="Alt Text">
80
+ <input
81
+ type="text"
82
+ value={block.alt || ""}
83
+ onFocus={snapshotOnFocus}
84
+ onChange={(e) => updateDebounced({ alt: e.target.value })}
85
+ className={INPUT_CLASS}
86
+ placeholder="Describe the audio for accessibility"
87
+ />
88
+ </SettingsField>
89
+ </SettingsSection>
90
+
91
+ {/* ── Metadata ── */}
92
+ <SettingsSection title="Metadata" icon={<OptionsIcon />}>
93
+ <SettingsField label="Title">
94
+ <input
95
+ type="text"
96
+ value={block.title || ""}
97
+ onFocus={snapshotOnFocus}
98
+ onChange={(e) => updateDebounced({ title: e.target.value })}
99
+ className={INPUT_CLASS}
100
+ placeholder="Track title"
101
+ />
102
+ </SettingsField>
103
+
104
+ <SettingsField label="Artist">
105
+ <input
106
+ type="text"
107
+ value={block.artist || ""}
108
+ onFocus={snapshotOnFocus}
109
+ onChange={(e) => updateDebounced({ artist: e.target.value })}
110
+ className={INPUT_CLASS}
111
+ placeholder="Artist name"
112
+ />
113
+ </SettingsField>
114
+
115
+ <SettingsField label="Cover Art" hint="Optional relative path to an image">
116
+ <AssetPathInput
117
+ value={block.cover_path || ""}
118
+ onFocus={snapshotOnFocus}
119
+ onChange={(v) => updateDebounced({ cover_path: v })}
120
+ placeholder="projects/slug/cover.jpg"
121
+ filterType="image"
122
+ />
123
+ </SettingsField>
124
+ </SettingsSection>
125
+
126
+ {/* ── Layout ── */}
127
+ <SettingsSection title="Layout" icon={<LayoutIcon />}>
128
+ <ResponsiveField
129
+ label="Width"
130
+ block={block as ContentBlock}
131
+ property="width"
132
+ onReset={() => resetOverride("width")}
133
+ >
134
+ <div className="flex gap-1">
135
+ {(
136
+ [
137
+ { value: "full", label: "Full" },
138
+ { value: "contained", label: "Contained" },
139
+ { value: "small", label: "Small" },
140
+ { value: "fill", label: "Fill" },
141
+ ] as const
142
+ ).map((opt) => (
143
+ <button
144
+ key={opt.value}
145
+ onClick={() => updateResponsive("width", opt.value)}
146
+ className={`flex-1 rounded border py-1 text-xs transition-colors ${
147
+ effectiveWidth === opt.value
148
+ ? "border-[#076bff] bg-[#076bff]/20 text-neutral-900"
149
+ : "border-neutral-200 bg-white text-neutral-500 hover:border-neutral-600"
150
+ }`}
151
+ >
152
+ {opt.label}
153
+ </button>
154
+ ))}
155
+ </div>
156
+ </ResponsiveField>
157
+ </SettingsSection>
158
+
159
+ {/* ── Appearance ── */}
160
+ <SettingsSection title="Appearance" icon={<AppearanceIcon />}>
161
+ <SettingsField label="Accent Color" hint="Play button + progress fill">
162
+ <ColorSwatchPicker
163
+ value={block.accent_color || "#4794E2"}
164
+ onChange={(value) => {
165
+ const hex = resolveColorHex(value) || "#4794E2";
166
+ update({ accent_color: hex });
167
+ }}
168
+ swatches={paletteSwatches}
169
+ />
170
+ </SettingsField>
171
+
172
+ <ResponsiveField
173
+ label="Border Radius"
174
+ block={block as ContentBlock}
175
+ property="border_radius"
176
+ onReset={() => resetOverride("border_radius")}
177
+ >
178
+ <div className="flex items-center gap-1.5">
179
+ <input
180
+ type="number"
181
+ value={String(getEffectiveValue<string>(block as ContentBlock, viewport, "border_radius", block.border_radius || "")).replace(/px$/i, "")}
182
+ onFocus={snapshotOnFocus}
183
+ onChange={(e) => {
184
+ store._pushSnapshot();
185
+ updateResponsive("border_radius", e.target.value.replace(/[^0-9]/g, ""));
186
+ }}
187
+ className={INPUT_CLASS}
188
+ placeholder="12"
189
+ min={0}
190
+ />
191
+ <span className="text-[10px] text-neutral-400 shrink-0">px</span>
192
+ </div>
193
+ </ResponsiveField>
194
+
195
+ <ResponsiveField
196
+ label="Shadow"
197
+ block={block as ContentBlock}
198
+ property="shadow"
199
+ onReset={() => resetOverride("shadow")}
200
+ >
201
+ <button
202
+ type="button"
203
+ onClick={() => {
204
+ const effectiveShadow = getEffectiveValue<boolean>(block as ContentBlock, viewport, "shadow", block.shadow || false);
205
+ updateResponsive("shadow", !effectiveShadow);
206
+ }}
207
+ className={`relative w-8 h-[18px] rounded-full transition-colors ${
208
+ getEffectiveValue<boolean>(block as ContentBlock, viewport, "shadow", block.shadow || false) ? "bg-[#076bff]" : "bg-neutral-200 hover:bg-neutral-300"
209
+ }`}
210
+ >
211
+ <span
212
+ className={`absolute top-[2px] w-[14px] h-[14px] rounded-full bg-white shadow-sm transition-transform ${
213
+ getEffectiveValue<boolean>(block as ContentBlock, viewport, "shadow", block.shadow || false) ? "left-[16px]" : "left-[2px]"
214
+ }`}
215
+ />
216
+ </button>
217
+ </ResponsiveField>
218
+ </SettingsSection>
219
+
220
+ {/* ── Playback ── */}
221
+ <SettingsSection title="Playback" icon={<PlaybackIcon />}>
222
+ <div className="space-y-1.5">
223
+ <StyledCheckbox
224
+ label="Autoplay"
225
+ checked={block.autoplay === true}
226
+ onChange={(checked) => update({ autoplay: checked })}
227
+ />
228
+ <StyledCheckbox
229
+ label="Loop"
230
+ checked={block.loop === true}
231
+ onChange={(checked) => update({ loop: checked })}
232
+ />
233
+ <StyledCheckbox
234
+ label="Muted"
235
+ checked={block.muted === true}
236
+ onChange={(checked) => update({ muted: checked })}
237
+ />
238
+ </div>
239
+ </SettingsSection>
240
+ </>
241
+ );
242
+ }