@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
@@ -3,7 +3,7 @@
3
3
  import { useState, useCallback, useRef, useEffect } from "react";
4
4
  import { useBuilderStore } from "../../../lib/builder/store";
5
5
  import type { PageSectionV2, CoverSection, ContentItem } from "../../../lib/sanity/types";
6
- import { isPageSectionV2, isCoverSection } from "../../../lib/sanity/types";
6
+ import { isPageSectionV2, isCoverSection, isColumnarSection } from "../../../lib/sanity/types";
7
7
  import { getEffectiveColumnsV2, buildColumnV2Overrides } from "../settings-panel/responsive-helpers";
8
8
  import { moveColumn as cascadeMoveColumn } from "../../../lib/builder/cascade";
9
9
  import type { DeviceViewport } from "../../../lib/builder/types";
@@ -80,6 +80,22 @@ export interface DropTarget {
80
80
  /** insert: grid position where the column will be inserted */
81
81
  insertRow?: number;
82
82
  insertCol?: number;
83
+ /**
84
+ * Whether the drop is to a DIFFERENT section than the source.
85
+ * - false → same-section drag (existing behavior).
86
+ * - true → cross-section move (must land on V2 or Cover; swap
87
+ * across sections is not supported and is reported as
88
+ * isValid=false below).
89
+ */
90
+ isCrossSection: boolean;
91
+ /**
92
+ * Whether the drop is legal. Set to false when:
93
+ * - target section is not a columnar section (parallax, custom-instance)
94
+ * - cross-section swap is attempted (only gap/insert cross sections)
95
+ * When false, the drop action is NOT executed on mouseup and the
96
+ * overlay renders in "invalid" (red) state.
97
+ */
98
+ isValid: boolean;
83
99
  }
84
100
 
85
101
  export interface InsertBetween {
@@ -97,6 +113,103 @@ export interface UseColumnDragReturn {
97
113
  startDrag: (e: React.MouseEvent, sectionKey: string, columnKey: string) => void;
98
114
  }
99
115
 
116
+ // ============================================
117
+ // Hit-test (shared by mousemove and mouseup)
118
+ // ============================================
119
+
120
+ /**
121
+ * Given a pointer event, resolve the topmost drop target under the cursor.
122
+ *
123
+ * - Priority order: Insert zone > Gap > Column (swap)
124
+ * - Cross-section drops are ALLOWED for Insert and Gap, but only if the
125
+ * target section is columnar (V2 or Cover). Non-columnar targets
126
+ * (parallax slide columns, custom instances) produce a target with
127
+ * `isValid: false` so the caller can show a red overlay.
128
+ * - Cross-section Swap is explicitly rejected (`isValid: false`). Swapping
129
+ * across sections is ambiguous UX; the user can drag into a gap/insert
130
+ * instead.
131
+ */
132
+ function hitTestDrop(
133
+ e: MouseEvent,
134
+ sourceSectionKey: string,
135
+ sourceColumnKey: string,
136
+ ): { target: DropTarget | null; insert: InsertBetween | null } {
137
+ const rows = useBuilderStore.getState().rows;
138
+ const isTargetColumnar = (sectionKey: string): boolean => {
139
+ const section = rows.find((r) => r._key === sectionKey);
140
+ return section ? isColumnarSection(section) : false;
141
+ };
142
+
143
+ const elements = document.elementsFromPoint(e.clientX, e.clientY);
144
+ for (const el of elements) {
145
+ const htmlEl = el as HTMLElement;
146
+
147
+ // Priority 1: Insert zone (between two columns, or at start/end of a row)
148
+ if (htmlEl.dataset.colV2Insert !== undefined) {
149
+ const ts = htmlEl.dataset.sectionKey!;
150
+ const isCross = ts !== sourceSectionKey;
151
+ const isValid = isTargetColumnar(ts);
152
+ const target: DropTarget = {
153
+ type: "insert",
154
+ sectionKey: ts,
155
+ insertRow: parseInt(htmlEl.dataset.insertRow!, 10),
156
+ insertCol: parseInt(htmlEl.dataset.insertCol!, 10),
157
+ isCrossSection: isCross,
158
+ isValid,
159
+ };
160
+ const leftKey = htmlEl.dataset.insertLeftKey;
161
+ const rightKey = htmlEl.dataset.insertRightKey;
162
+ const insert: InsertBetween | null =
163
+ leftKey && rightKey ? { leftKey, rightKey } : null;
164
+ return { target, insert };
165
+ }
166
+
167
+ // Priority 2: Gap (empty grid cell)
168
+ if (htmlEl.dataset.colV2Gap !== undefined) {
169
+ const ts = htmlEl.dataset.sectionKey!;
170
+ const isCross = ts !== sourceSectionKey;
171
+ const isValid = isTargetColumnar(ts);
172
+ return {
173
+ target: {
174
+ type: "gap",
175
+ sectionKey: ts,
176
+ gapRow: parseInt(htmlEl.dataset.gapRow!, 10),
177
+ gapCol: parseInt(htmlEl.dataset.gapCol!, 10),
178
+ gapSpan: parseInt(htmlEl.dataset.gapSpan!, 10),
179
+ isCrossSection: isCross,
180
+ isValid,
181
+ },
182
+ insert: null,
183
+ };
184
+ }
185
+
186
+ // Priority 3: Column (swap). Valid both same-section AND cross-section
187
+ // (as long as target is a columnar section — V2 or Cover). Cross-section
188
+ // swap routes to `swapColumnsBetweenSections` at drop time; same-section
189
+ // swap routes to `swapColumnV2`.
190
+ if (htmlEl.dataset.colV2Droptarget !== undefined) {
191
+ const ts = htmlEl.dataset.sectionKey!;
192
+ const tc = htmlEl.dataset.columnKey!;
193
+ // Skip if hovering over self (within same section)
194
+ if (ts === sourceSectionKey && tc === sourceColumnKey) continue;
195
+ const isCross = ts !== sourceSectionKey;
196
+ const isValid = isTargetColumnar(ts);
197
+ return {
198
+ target: {
199
+ type: "swap",
200
+ sectionKey: ts,
201
+ columnKey: tc,
202
+ isCrossSection: isCross,
203
+ isValid,
204
+ },
205
+ insert: null,
206
+ };
207
+ }
208
+ }
209
+
210
+ return { target: null, insert: null };
211
+ }
212
+
100
213
  // ============================================
101
214
  // Responsive Helper Functions (private)
102
215
  // ============================================
@@ -252,6 +365,8 @@ export function useColumnDrag(): UseColumnDragReturn {
252
365
  swapColumnV2: s.swapColumnV2,
253
366
  moveColumnToGapV2: s.moveColumnToGapV2,
254
367
  moveColumnV2: s.moveColumnV2,
368
+ moveColumnBetweenSections: s.moveColumnBetweenSections,
369
+ swapColumnsBetweenSections: s.swapColumnsBetweenSections,
255
370
  updateSectionV2Responsive: s.updateSectionV2Responsive,
256
371
  };
257
372
  };
@@ -296,60 +411,12 @@ export function useColumnDrag(): UseColumnDragReturn {
296
411
 
297
412
  const { sectionKey, columnKey } = dragRef.current;
298
413
 
299
- // 2. Hit-test via elementsFromPoint
300
- const elements = document.elementsFromPoint(e.clientX, e.clientY);
301
- let newTarget: DropTarget | null = null;
302
- let newInsert: InsertBetween | null = null;
303
-
304
- for (const el of elements) {
305
- const htmlEl = el as HTMLElement;
306
-
307
- // Priority 1: Insert zone
308
- if (htmlEl.dataset.colV2Insert !== undefined) {
309
- const targetSection = htmlEl.dataset.sectionKey;
310
- if (targetSection !== sectionKey) continue;
311
- newTarget = {
312
- type: "insert",
313
- sectionKey: targetSection,
314
- insertRow: parseInt(htmlEl.dataset.insertRow!, 10),
315
- insertCol: parseInt(htmlEl.dataset.insertCol!, 10),
316
- };
317
- const leftKey = htmlEl.dataset.insertLeftKey;
318
- const rightKey = htmlEl.dataset.insertRightKey;
319
- if (leftKey && rightKey) {
320
- newInsert = { leftKey, rightKey };
321
- }
322
- break;
323
- }
324
-
325
- // Priority 2: Gap
326
- if (htmlEl.dataset.colV2Gap !== undefined) {
327
- const targetSection = htmlEl.dataset.sectionKey;
328
- if (targetSection !== sectionKey) continue;
329
- newTarget = {
330
- type: "gap",
331
- sectionKey: targetSection,
332
- gapRow: parseInt(htmlEl.dataset.gapRow!, 10),
333
- gapCol: parseInt(htmlEl.dataset.gapCol!, 10),
334
- gapSpan: parseInt(htmlEl.dataset.gapSpan!, 10),
335
- };
336
- break;
337
- }
338
-
339
- // Priority 3: Column (swap)
340
- if (htmlEl.dataset.colV2Droptarget !== undefined) {
341
- const targetSection = htmlEl.dataset.sectionKey;
342
- const targetColumn = htmlEl.dataset.columnKey;
343
- if (targetSection !== sectionKey) continue;
344
- if (targetColumn === columnKey) continue; // skip self
345
- newTarget = {
346
- type: "swap",
347
- sectionKey: targetSection,
348
- columnKey: targetColumn,
349
- };
350
- break;
351
- }
352
- }
414
+ // 2. Hit-test via shared helper (returns DropTarget with isValid/isCrossSection)
415
+ const { target: newTarget, insert: newInsert } = hitTestDrop(
416
+ e,
417
+ sectionKey,
418
+ columnKey,
419
+ );
353
420
 
354
421
  setDropTarget(newTarget);
355
422
  setInsertBetween(newTarget?.type === "insert" ? newInsert : null);
@@ -378,93 +445,100 @@ export function useColumnDrag(): UseColumnDragReturn {
378
445
  const { sectionKey, columnKey } = dragRef.current;
379
446
  dragRef.current.active = false;
380
447
 
381
- // Final hit-test at mouseup position
382
- const elements = document.elementsFromPoint(e.clientX, e.clientY);
383
- let finalTarget: DropTarget | null = null;
384
-
385
- for (const el of elements) {
386
- const htmlEl = el as HTMLElement;
387
-
388
- // Priority 1: Insert zone
389
- if (htmlEl.dataset.colV2Insert !== undefined) {
390
- const ts = htmlEl.dataset.sectionKey;
391
- if (ts !== sectionKey) continue;
392
- finalTarget = {
393
- type: "insert",
394
- sectionKey: ts,
395
- insertRow: parseInt(htmlEl.dataset.insertRow!, 10),
396
- insertCol: parseInt(htmlEl.dataset.insertCol!, 10),
397
- };
398
- break;
399
- }
400
-
401
- // Priority 2: Gap
402
- if (htmlEl.dataset.colV2Gap !== undefined) {
403
- const ts = htmlEl.dataset.sectionKey;
404
- if (ts !== sectionKey) continue;
405
- finalTarget = {
406
- type: "gap",
407
- sectionKey: ts,
408
- gapRow: parseInt(htmlEl.dataset.gapRow!, 10),
409
- gapCol: parseInt(htmlEl.dataset.gapCol!, 10),
410
- gapSpan: parseInt(htmlEl.dataset.gapSpan!, 10),
411
- };
412
- break;
413
- }
448
+ // Final hit-test at mouseup position (shared with mousemove)
449
+ const { target: finalTarget } = hitTestDrop(e, sectionKey, columnKey);
414
450
 
415
- // Priority 3: Column (swap)
416
- if (htmlEl.dataset.colV2Droptarget !== undefined) {
417
- const ts = htmlEl.dataset.sectionKey;
418
- const tc = htmlEl.dataset.columnKey;
419
- if (ts !== sectionKey || tc === columnKey) continue;
420
- finalTarget = { type: "swap", sectionKey: ts, columnKey: tc };
421
- break;
422
- }
423
- }
424
-
425
- // Execute the drop action — read actions fresh from store (RC-002)
426
- if (finalTarget) {
451
+ // Execute the drop action — read actions fresh from store (RC-002).
452
+ // Only valid targets execute; invalid (e.g. cross-section swap, drop
453
+ // on parallax) fall through silently — overlay already showed red.
454
+ if (finalTarget && finalTarget.isValid) {
427
455
  const storeState = useBuilderStore.getState();
428
456
  const activeViewport = storeState.activeViewport;
429
457
  const isResponsive = activeViewport !== "desktop";
430
458
  const actions = getActions();
431
459
 
432
- if (finalTarget.type === "swap" && finalTarget.columnKey) {
433
- if (!isResponsive) {
434
- actions.swapColumnV2(sectionKey, columnKey, finalTarget.columnKey);
435
- } else {
436
- executeResponsiveSwap(
437
- sectionKey, columnKey, finalTarget.columnKey, activeViewport,
438
- actions.updateSectionV2Responsive
460
+ if (finalTarget.isCrossSection) {
461
+ // Cross-section drops supported:
462
+ // - gap / insert → `moveColumnBetweenSections`
463
+ // - swap → `swapColumnsBetweenSections` (each column adopts the
464
+ // other's position/row/span, same semantic as intra-section swap)
465
+ // Responsive cross-section moves are not yet supported in this
466
+ // first iteration — treat as desktop move (span/col clamped by
467
+ // helpers). The drop is still written to the Sanity doc.
468
+ if (finalTarget.type === "gap") {
469
+ actions.moveColumnBetweenSections(
470
+ sectionKey,
471
+ columnKey,
472
+ finalTarget.sectionKey,
473
+ finalTarget.gapRow!,
474
+ finalTarget.gapCol!,
475
+ finalTarget.gapSpan!,
439
476
  );
440
- }
441
- } else if (finalTarget.type === "gap") {
442
- if (!isResponsive) {
443
- actions.moveColumnToGapV2(
444
- sectionKey, columnKey,
445
- finalTarget.gapRow!, finalTarget.gapCol!, finalTarget.gapSpan!
477
+ } else if (finalTarget.type === "insert") {
478
+ // For insert drops, preserve the source column's span. The helper
479
+ // clamps to target's grid if needed.
480
+ const sourceCol = useBuilderStore
481
+ .getState()
482
+ .rows.find((r) => r._key === sectionKey);
483
+ const sourceSpan =
484
+ sourceCol && isColumnarSection(sourceCol)
485
+ ? sourceCol.columns.find((c) => c._key === columnKey)?.span ?? 12
486
+ : 12;
487
+ actions.moveColumnBetweenSections(
488
+ sectionKey,
489
+ columnKey,
490
+ finalTarget.sectionKey,
491
+ finalTarget.insertRow!,
492
+ finalTarget.insertCol!,
493
+ sourceSpan,
446
494
  );
447
- } else {
448
- executeResponsiveGapMove(
449
- sectionKey, columnKey,
450
- finalTarget.gapRow!, finalTarget.gapCol!, finalTarget.gapSpan!,
451
- activeViewport,
452
- actions.updateSectionV2Responsive
495
+ } else if (finalTarget.type === "swap" && finalTarget.columnKey) {
496
+ actions.swapColumnsBetweenSections(
497
+ sectionKey,
498
+ columnKey,
499
+ finalTarget.sectionKey,
500
+ finalTarget.columnKey,
453
501
  );
454
502
  }
455
- } else if (finalTarget.type === "insert") {
456
- if (!isResponsive) {
457
- actions.moveColumnV2(
458
- sectionKey, columnKey,
459
- finalTarget.insertRow!, finalTarget.insertCol!
460
- );
461
- } else {
462
- executeResponsiveInsert(
463
- sectionKey, columnKey,
464
- finalTarget.insertRow!, finalTarget.insertCol!,
465
- activeViewport,
466
- actions.updateSectionV2Responsive
467
- );
503
+ } else {
504
+ // Same-section — existing behavior preserved exactly
505
+ if (finalTarget.type === "swap" && finalTarget.columnKey) {
506
+ if (!isResponsive) {
507
+ actions.swapColumnV2(sectionKey, columnKey, finalTarget.columnKey);
508
+ } else {
509
+ executeResponsiveSwap(
510
+ sectionKey, columnKey, finalTarget.columnKey, activeViewport,
511
+ actions.updateSectionV2Responsive
512
+ );
513
+ }
514
+ } else if (finalTarget.type === "gap") {
515
+ if (!isResponsive) {
516
+ actions.moveColumnToGapV2(
517
+ sectionKey, columnKey,
518
+ finalTarget.gapRow!, finalTarget.gapCol!, finalTarget.gapSpan!
519
+ );
520
+ } else {
521
+ executeResponsiveGapMove(
522
+ sectionKey, columnKey,
523
+ finalTarget.gapRow!, finalTarget.gapCol!, finalTarget.gapSpan!,
524
+ activeViewport,
525
+ actions.updateSectionV2Responsive
526
+ );
527
+ }
528
+ } else if (finalTarget.type === "insert") {
529
+ if (!isResponsive) {
530
+ actions.moveColumnV2(
531
+ sectionKey, columnKey,
532
+ finalTarget.insertRow!, finalTarget.insertCol!
533
+ );
534
+ } else {
535
+ executeResponsiveInsert(
536
+ sectionKey, columnKey,
537
+ finalTarget.insertRow!, finalTarget.insertCol!,
538
+ activeViewport,
539
+ actions.updateSectionV2Responsive
540
+ );
541
+ }
468
542
  }
469
543
  }
470
544
  }
@@ -0,0 +1,120 @@
1
+ "use client";
2
+
3
+ import { adminAssetUrl, adminThumbUrl } from "../../../lib/assets";
4
+ import type { AudioBlock } from "../../../lib/sanity/types";
5
+
6
+ /**
7
+ * LiveAudioPreview — Static preview for builder canvas.
8
+ *
9
+ * Same layout as the runtime renderer but no audio element / no playback —
10
+ * a frozen snapshot with a 0% progress bar, a play glyph, and a dummy
11
+ * `0:00 / 0:00` time label. Metadata (title / artist) and cover art
12
+ * render when present.
13
+ */
14
+
15
+ const widthStyleMap: Record<string, { width: string; margin?: string }> = {
16
+ full: { width: "100%" },
17
+ contained: { width: "75%", margin: "0 auto" },
18
+ small: { width: "50%", margin: "0 auto" },
19
+ };
20
+
21
+ export default function LiveAudioPreview({ block }: { block: AudioBlock }) {
22
+ const accent = block.accent_color || "#4794E2";
23
+ const coverSrc = block.cover_path ? (adminThumbUrl(block.cover_path) || adminAssetUrl(block.cover_path)) : null;
24
+
25
+ const isFill = block.width === "fill";
26
+ const widthStyle = isFill ? { width: "100%" } : (widthStyleMap[block.width ?? "contained"] || widthStyleMap.contained);
27
+
28
+ const rawRadius = block.border_radius ? String(block.border_radius).replace(/[a-z%]+$/i, "") : "";
29
+ const borderRadius = rawRadius && !isNaN(Number(rawRadius)) ? `${rawRadius}px` : "12px";
30
+
31
+ const hasMetaText = !!(block.title || block.artist);
32
+ const hasAsset = !!block.asset_path;
33
+
34
+ const containerStyle: React.CSSProperties = {
35
+ ...widthStyle,
36
+ display: "flex",
37
+ alignItems: "center",
38
+ gap: 14,
39
+ padding: "12px 16px",
40
+ background: "#fafafa",
41
+ border: "1px solid #ececec",
42
+ borderRadius,
43
+ boxShadow: block.shadow ? "0 8px 24px -12px rgba(0,0,0,0.25)" : undefined,
44
+ overflow: "hidden",
45
+ opacity: hasAsset ? 1 : 0.75,
46
+ };
47
+
48
+ return (
49
+ <div style={containerStyle}>
50
+ {coverSrc ? (
51
+ <div style={{ width: 52, height: 52, flexShrink: 0, borderRadius: 8, overflow: "hidden", background: "#eee" }}>
52
+ {/* eslint-disable-next-line @next/next/no-img-element */}
53
+ <img src={coverSrc} alt={block.alt || block.title || ""} style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }} />
54
+ </div>
55
+ ) : null}
56
+
57
+ <div
58
+ aria-hidden
59
+ style={{
60
+ width: 40,
61
+ height: 40,
62
+ flexShrink: 0,
63
+ borderRadius: "50%",
64
+ background: accent,
65
+ color: "#fff",
66
+ display: "flex",
67
+ alignItems: "center",
68
+ justifyContent: "center",
69
+ }}
70
+ >
71
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style={{ marginLeft: 2 }}>
72
+ <path d="M8 5v14l11-7z" />
73
+ </svg>
74
+ </div>
75
+
76
+ <div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 4 }}>
77
+ {hasMetaText ? (
78
+ <div style={{ display: "flex", alignItems: "baseline", gap: 6, minWidth: 0 }}>
79
+ {block.title && (
80
+ <span style={{ fontSize: 13, fontWeight: 600, color: "#111", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
81
+ {block.title}
82
+ </span>
83
+ )}
84
+ {block.artist && (
85
+ <span style={{ fontSize: 12, color: "#777", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
86
+ {block.artist}
87
+ </span>
88
+ )}
89
+ </div>
90
+ ) : (
91
+ !hasAsset && (
92
+ <span style={{ fontSize: 11, color: "#8a8f98" }}>Audio — pick a file</span>
93
+ )
94
+ )}
95
+ <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
96
+ <div style={{ flex: 1, height: 4, background: "#e5e5e5", borderRadius: 999, position: "relative" }}>
97
+ <div style={{ position: "absolute", inset: 0, width: "0%", background: accent, borderRadius: 999 }} />
98
+ <div
99
+ style={{
100
+ position: "absolute",
101
+ top: "50%",
102
+ left: "0%",
103
+ width: 10,
104
+ height: 10,
105
+ marginTop: -5,
106
+ marginLeft: -5,
107
+ borderRadius: "50%",
108
+ background: "#fff",
109
+ boxShadow: `0 0 0 2px ${accent}`,
110
+ }}
111
+ />
112
+ </div>
113
+ <span style={{ fontSize: 11, color: "#777", fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap" }}>
114
+ 0:00 / 0:00
115
+ </span>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ );
120
+ }
@@ -0,0 +1,176 @@
1
+ "use client";
2
+
3
+ import { adminAssetUrl, adminThumbUrl } from "../../../lib/assets";
4
+ import type { BeforeAfterBlock } from "../../../lib/sanity/types";
5
+
6
+ /**
7
+ * LiveBeforeAfterPreview — Static preview for builder canvas.
8
+ *
9
+ * Shows both assets with a fixed 50% split (or the configured `initial_position`)
10
+ * and a divider line + knob — no drag interaction in the builder. Videos are
11
+ * represented by a poster-style thumbnail with a play glyph (no streaming /
12
+ * autoplay inside the builder).
13
+ */
14
+
15
+ const widthStyleMap: Record<string, { width: string; margin?: string }> = {
16
+ full: { width: "100%" },
17
+ contained: { width: "75%", margin: "0 auto" },
18
+ small: { width: "50%", margin: "0 auto" },
19
+ };
20
+
21
+ const aspectMap: Record<string, string | undefined> = {
22
+ auto: undefined,
23
+ "16:9": "16/9",
24
+ "4:3": "4/3",
25
+ "1:1": "1/1",
26
+ "21:9": "21/9",
27
+ };
28
+
29
+ function clamp(n: number, lo = 0, hi = 100): number {
30
+ return Math.max(lo, Math.min(hi, n));
31
+ }
32
+
33
+ function PreviewMedia({
34
+ type,
35
+ path,
36
+ alt,
37
+ }: {
38
+ type: "image" | "video";
39
+ path: string;
40
+ alt: string;
41
+ }) {
42
+ const src = adminThumbUrl(path) || adminAssetUrl(path);
43
+ const commonStyle: React.CSSProperties = {
44
+ position: "absolute",
45
+ inset: 0,
46
+ width: "100%",
47
+ height: "100%",
48
+ objectFit: "cover",
49
+ display: "block",
50
+ pointerEvents: "none",
51
+ userSelect: "none",
52
+ };
53
+ return (
54
+ <>
55
+ {/* eslint-disable-next-line @next/next/no-img-element */}
56
+ <img src={src} alt={alt} loading="lazy" decoding="async" draggable={false} style={commonStyle} />
57
+ {type === "video" && (
58
+ <div style={{ position: "absolute", inset: 0, display: "flex", alignItems: "center", justifyContent: "center", pointerEvents: "none" }}>
59
+ <div style={{ width: 44, height: 44, borderRadius: "50%", background: "rgba(0,0,0,0.55)", display: "flex", alignItems: "center", justifyContent: "center" }}>
60
+ <span style={{ color: "#FFFFFF", fontSize: 16, marginLeft: 2 }}>&#9654;</span>
61
+ </div>
62
+ </div>
63
+ )}
64
+ </>
65
+ );
66
+ }
67
+
68
+ export default function LiveBeforeAfterPreview({ block }: { block: BeforeAfterBlock }) {
69
+ const beforeType = block.before_media_type ?? "image";
70
+ const afterType = block.after_media_type ?? "image";
71
+ const orientation = block.orientation ?? "horizontal";
72
+ const position = clamp(block.initial_position ?? 50);
73
+ const handleColor = block.handle_color || "#FFFFFF";
74
+
75
+ const isFill = block.width === "fill";
76
+ const widthStyle = isFill ? {} : (widthStyleMap[block.width ?? "full"] || widthStyleMap.full);
77
+ const aspect = isFill ? undefined : aspectMap[block.aspect_ratio ?? "16:9"] ?? "16/9";
78
+
79
+ const rawRadius = block.border_radius ? String(block.border_radius).replace(/[a-z%]+$/i, "") : "";
80
+ const borderRadius = rawRadius && !isNaN(Number(rawRadius)) ? `${rawRadius}px` : undefined;
81
+
82
+ const hasBefore = !!block.before_asset_path;
83
+ const hasAfter = !!block.after_asset_path;
84
+
85
+ // Empty state: no assets on either side
86
+ if (!hasBefore && !hasAfter) {
87
+ const wrapperStyle: React.CSSProperties = isFill
88
+ ? { position: "absolute", inset: 0 }
89
+ : { width: "100%" };
90
+ return (
91
+ <div style={wrapperStyle}>
92
+ <div className="w-full h-full min-h-[240px] rounded flex flex-col items-center justify-center gap-2.5" style={{ background: "#f4f4f4" }}>
93
+ <svg width="56" height="56" viewBox="0 0 56 56" fill="none" aria-hidden="true">
94
+ <rect x="6" y="10" width="44" height="36" rx="3" stroke="#b0b5bd" strokeWidth="1.5" fill="#FFFFFF" />
95
+ <line x1="28" y1="10" x2="28" y2="46" stroke="#b0b5bd" strokeWidth="1.5" />
96
+ <circle cx="28" cy="28" r="5" fill="#FFFFFF" stroke="#b0b5bd" strokeWidth="1.5" />
97
+ </svg>
98
+ <span className="text-[11px] text-neutral-500">Before / After — pick two assets</span>
99
+ </div>
100
+ </div>
101
+ );
102
+ }
103
+
104
+ const afterClip = orientation === "horizontal"
105
+ ? `inset(0 0 0 ${position}%)`
106
+ : `inset(${position}% 0 0 0)`;
107
+
108
+ const dividerStyle: React.CSSProperties = orientation === "horizontal"
109
+ ? { position: "absolute", top: 0, bottom: 0, left: `${position}%`, width: 2, transform: "translateX(-1px)", background: handleColor, pointerEvents: "none" }
110
+ : { position: "absolute", left: 0, right: 0, top: `${position}%`, height: 2, transform: "translateY(-1px)", background: handleColor, pointerEvents: "none" };
111
+
112
+ const knobStyle: React.CSSProperties = {
113
+ position: "absolute",
114
+ left: "50%",
115
+ top: "50%",
116
+ transform: "translate(-50%, -50%)",
117
+ width: 32,
118
+ height: 32,
119
+ borderRadius: "50%",
120
+ background: handleColor,
121
+ boxShadow: "0 2px 8px rgba(0,0,0,0.35)",
122
+ display: "flex",
123
+ alignItems: "center",
124
+ justifyContent: "center",
125
+ pointerEvents: "none",
126
+ };
127
+
128
+ const ArrowIcon = orientation === "horizontal" ? (
129
+ <svg width="14" height="14" viewBox="0 0 18 18" fill="none" aria-hidden="true">
130
+ <path d="M5 4 L1 9 L5 14" stroke="#111" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
131
+ <path d="M13 4 L17 9 L13 14" stroke="#111" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
132
+ </svg>
133
+ ) : (
134
+ <svg width="14" height="14" viewBox="0 0 18 18" fill="none" aria-hidden="true">
135
+ <path d="M4 5 L9 1 L14 5" stroke="#111" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
136
+ <path d="M4 13 L9 17 L14 13" stroke="#111" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
137
+ </svg>
138
+ );
139
+
140
+ const frameStyle: React.CSSProperties = isFill
141
+ ? { position: "absolute", inset: 0, borderRadius, overflow: "hidden", background: "#222" }
142
+ : { position: "relative", ...widthStyle, aspectRatio: aspect, borderRadius, overflow: "hidden", background: "#222" };
143
+
144
+ const shadowClass = block.shadow ? "shadow-lg" : "";
145
+
146
+ return (
147
+ <div className={shadowClass} style={frameStyle}>
148
+ {/* Before layer */}
149
+ <div style={{ position: "absolute", inset: 0 }}>
150
+ {hasBefore ? (
151
+ <PreviewMedia type={beforeType} path={block.before_asset_path} alt={block.before_alt || ""} />
152
+ ) : (
153
+ <div style={{ position: "absolute", inset: 0, background: "#e7e9ed", display: "flex", alignItems: "center", justifyContent: "center" }}>
154
+ <span style={{ fontSize: 11, color: "#8a8f98" }}>No before asset</span>
155
+ </div>
156
+ )}
157
+ </div>
158
+
159
+ {/* After layer — clipped by position */}
160
+ <div style={{ position: "absolute", inset: 0, clipPath: afterClip, WebkitClipPath: afterClip }}>
161
+ {hasAfter ? (
162
+ <PreviewMedia type={afterType} path={block.after_asset_path} alt={block.after_alt || ""} />
163
+ ) : (
164
+ <div style={{ position: "absolute", inset: 0, background: "#d8dbe0", display: "flex", alignItems: "center", justifyContent: "center" }}>
165
+ <span style={{ fontSize: 11, color: "#8a8f98" }}>No after asset</span>
166
+ </div>
167
+ )}
168
+ </div>
169
+
170
+ {/* Divider + knob */}
171
+ <div style={dividerStyle}>
172
+ <div style={knobStyle}>{ArrowIcon}</div>
173
+ </div>
174
+ </div>
175
+ );
176
+ }