@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.
- package/README.md +151 -36
- package/app/admin/layout.tsx +145 -152
- package/components/blocks/AudioBlockRenderer.tsx +286 -0
- package/components/blocks/BeforeAfterBlockRenderer.tsx +274 -0
- package/components/builder/BlockCardIcons.tsx +89 -0
- package/components/builder/BlockTypePicker.tsx +2 -0
- package/components/builder/ColumnDragContext.tsx +5 -0
- package/components/builder/ColumnDragOverlay.tsx +38 -11
- package/components/builder/CoverSectionCanvas.tsx +90 -2
- package/components/builder/InsertionLines.tsx +9 -1
- package/components/builder/SectionV2Canvas.tsx +32 -6
- package/components/builder/SectionV2Column.tsx +5 -1
- package/components/builder/asset-browser/R2BrowserContent.tsx +23 -6
- package/components/builder/asset-browser/helpers.ts +4 -0
- package/components/builder/asset-browser/types.ts +2 -1
- package/components/builder/blockStyles.tsx +12 -0
- package/components/builder/editors/AudioBlockEditor.tsx +242 -0
- package/components/builder/editors/BeforeAfterBlockEditor.tsx +360 -0
- package/components/builder/editors/shared.tsx +1 -1
- package/components/builder/hooks/useColumnDrag.ts +206 -132
- package/components/builder/live-preview/LiveAudioPreview.tsx +120 -0
- package/components/builder/live-preview/LiveBeforeAfterPreview.tsx +176 -0
- package/lib/animation/enter-types.ts +2 -0
- package/lib/animation/hover-effect-types.ts +2 -0
- package/lib/builder/block-registrations.ts +83 -1
- package/lib/builder/store-helpers.ts +302 -1
- package/lib/builder/store-sections.ts +60 -0
- package/lib/builder/types-slices.ts +27 -0
- package/lib/builder/types.ts +2 -0
- package/lib/sanity/types.ts +75 -0
- package/lib/version.ts +1 -1
- package/package.json +1 -1
- package/sanity/schemas/blocks/audioBlock.ts +69 -0
- package/sanity/schemas/blocks/beforeAfterBlock.ts +121 -0
- package/sanity/schemas/blocks/index.ts +3 -1
- 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
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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.
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
449
|
-
sectionKey,
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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 }}>▶</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
|
+
}
|