@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
@@ -0,0 +1,286 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import type { AudioBlock } from "../../lib/sanity/types";
5
+ import { useAssetUrl } from "../../lib/contexts/AssetContext";
6
+ import { handleImageRetry } from "../../lib/asset-retry";
7
+
8
+ /**
9
+ * AudioBlockRenderer — minimal custom audio player.
10
+ *
11
+ * Layout: optional cover art + (title / artist) + play button + progress bar + time.
12
+ * Accent color applies to the play button background and progress fill.
13
+ * Uses the HTML5 Audio element via a ref; progress + duration driven by
14
+ * timeupdate / loadedmetadata events.
15
+ */
16
+
17
+ const widthStyleMap: Record<string, { width: string; margin?: string }> = {
18
+ full: { width: "100%" },
19
+ contained: { width: "75%", margin: "0 auto" },
20
+ small: { width: "50%", margin: "0 auto" },
21
+ };
22
+
23
+ function formatTime(seconds: number): string {
24
+ if (!isFinite(seconds) || seconds < 0) return "0:00";
25
+ const m = Math.floor(seconds / 60);
26
+ const s = Math.floor(seconds % 60);
27
+ return `${m}:${s.toString().padStart(2, "0")}`;
28
+ }
29
+
30
+ export default function AudioBlockRenderer({ block }: { block: AudioBlock }) {
31
+ const resolveAsset = useAssetUrl();
32
+
33
+ const src = resolveAsset(block.asset_path);
34
+ const coverSrc = block.cover_path ? resolveAsset(block.cover_path) : null;
35
+ const accent = block.accent_color || "#4794E2";
36
+
37
+ const isFill = block.width === "fill";
38
+ const widthStyle = isFill ? {} : (widthStyleMap[block.width ?? "contained"] || widthStyleMap.contained);
39
+
40
+ const rawRadius = block.border_radius ? String(block.border_radius).replace(/[a-z%]+$/i, "") : "";
41
+ const borderRadius = rawRadius && !isNaN(Number(rawRadius)) ? `${rawRadius}px` : "12px";
42
+
43
+ const autoplay = block.autoplay === true;
44
+ const loop = block.loop === true;
45
+ const initialMuted = block.muted === true;
46
+
47
+ const audioRef = useRef<HTMLAudioElement | null>(null);
48
+ const progressRef = useRef<HTMLDivElement | null>(null);
49
+ const draggingRef = useRef(false);
50
+
51
+ const [isPlaying, setIsPlaying] = useState<boolean>(false);
52
+ const [currentTime, setCurrentTime] = useState<number>(0);
53
+ const [duration, setDuration] = useState<number>(0);
54
+ const [muted, setMuted] = useState<boolean>(initialMuted);
55
+
56
+ useEffect(() => {
57
+ const a = audioRef.current;
58
+ if (!a) return;
59
+ const onTime = () => setCurrentTime(a.currentTime);
60
+ const onMeta = () => setDuration(a.duration || 0);
61
+ const onPlay = () => setIsPlaying(true);
62
+ const onPause = () => setIsPlaying(false);
63
+ const onEnded = () => setIsPlaying(false);
64
+ a.addEventListener("timeupdate", onTime);
65
+ a.addEventListener("loadedmetadata", onMeta);
66
+ a.addEventListener("durationchange", onMeta);
67
+ a.addEventListener("play", onPlay);
68
+ a.addEventListener("pause", onPause);
69
+ a.addEventListener("ended", onEnded);
70
+ return () => {
71
+ a.removeEventListener("timeupdate", onTime);
72
+ a.removeEventListener("loadedmetadata", onMeta);
73
+ a.removeEventListener("durationchange", onMeta);
74
+ a.removeEventListener("play", onPlay);
75
+ a.removeEventListener("pause", onPause);
76
+ a.removeEventListener("ended", onEnded);
77
+ };
78
+ }, []);
79
+
80
+ const togglePlay = useCallback(() => {
81
+ const a = audioRef.current;
82
+ if (!a) return;
83
+ if (a.paused) {
84
+ a.play().catch(() => { /* autoplay/permission rejection — leave paused */ });
85
+ } else {
86
+ a.pause();
87
+ }
88
+ }, []);
89
+
90
+ const seekFromEvent = useCallback((clientX: number) => {
91
+ const el = progressRef.current;
92
+ const a = audioRef.current;
93
+ if (!el || !a || !duration) return;
94
+ const rect = el.getBoundingClientRect();
95
+ const pct = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
96
+ a.currentTime = pct * duration;
97
+ setCurrentTime(a.currentTime);
98
+ }, [duration]);
99
+
100
+ const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
101
+ draggingRef.current = true;
102
+ (e.target as Element).setPointerCapture?.(e.pointerId);
103
+ seekFromEvent(e.clientX);
104
+ }, [seekFromEvent]);
105
+
106
+ const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
107
+ if (!draggingRef.current) return;
108
+ seekFromEvent(e.clientX);
109
+ }, [seekFromEvent]);
110
+
111
+ const onPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
112
+ draggingRef.current = false;
113
+ (e.target as Element).releasePointerCapture?.(e.pointerId);
114
+ }, []);
115
+
116
+ const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
117
+
118
+ const containerStyle: React.CSSProperties = {
119
+ ...widthStyle,
120
+ display: "flex",
121
+ alignItems: "center",
122
+ gap: 14,
123
+ padding: "12px 16px",
124
+ background: "#fafafa",
125
+ border: "1px solid #ececec",
126
+ borderRadius,
127
+ boxShadow: block.shadow ? "0 8px 24px -12px rgba(0,0,0,0.25)" : undefined,
128
+ overflow: "hidden",
129
+ };
130
+
131
+ const hasMetaText = !!(block.title || block.artist);
132
+
133
+ return (
134
+ <div style={containerStyle}>
135
+ <audio
136
+ ref={audioRef}
137
+ src={src}
138
+ autoPlay={autoplay}
139
+ loop={loop}
140
+ muted={muted}
141
+ preload="metadata"
142
+ />
143
+
144
+ {coverSrc && (
145
+ <div style={{ width: 52, height: 52, flexShrink: 0, borderRadius: 8, overflow: "hidden", background: "#eee" }}>
146
+ {/* eslint-disable-next-line @next/next/no-img-element */}
147
+ <img
148
+ src={coverSrc}
149
+ alt={block.alt || block.title || "Audio cover"}
150
+ onError={handleImageRetry}
151
+ style={{ width: "100%", height: "100%", objectFit: "cover", display: "block" }}
152
+ />
153
+ </div>
154
+ )}
155
+
156
+ <button
157
+ type="button"
158
+ onClick={togglePlay}
159
+ aria-label={isPlaying ? "Pause" : "Play"}
160
+ style={{
161
+ width: 40,
162
+ height: 40,
163
+ flexShrink: 0,
164
+ borderRadius: "50%",
165
+ border: "none",
166
+ background: accent,
167
+ color: "#fff",
168
+ cursor: "pointer",
169
+ display: "flex",
170
+ alignItems: "center",
171
+ justifyContent: "center",
172
+ padding: 0,
173
+ }}
174
+ >
175
+ {isPlaying ? (
176
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
177
+ <rect x="6" y="5" width="4" height="14" rx="1" />
178
+ <rect x="14" y="5" width="4" height="14" rx="1" />
179
+ </svg>
180
+ ) : (
181
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden style={{ marginLeft: 2 }}>
182
+ <path d="M8 5v14l11-7z" />
183
+ </svg>
184
+ )}
185
+ </button>
186
+
187
+ <div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 4 }}>
188
+ {hasMetaText && (
189
+ <div style={{ display: "flex", alignItems: "baseline", gap: 6, minWidth: 0 }}>
190
+ {block.title && (
191
+ <span style={{ fontSize: 13, fontWeight: 600, color: "#111", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
192
+ {block.title}
193
+ </span>
194
+ )}
195
+ {block.artist && (
196
+ <span style={{ fontSize: 12, color: "#777", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
197
+ {block.artist}
198
+ </span>
199
+ )}
200
+ </div>
201
+ )}
202
+ <div style={{ display: "flex", alignItems: "center", gap: 10 }}>
203
+ <div
204
+ ref={progressRef}
205
+ onPointerDown={onPointerDown}
206
+ onPointerMove={onPointerMove}
207
+ onPointerUp={onPointerUp}
208
+ onPointerCancel={onPointerUp}
209
+ role="slider"
210
+ aria-label="Playback position"
211
+ aria-valuemin={0}
212
+ aria-valuemax={100}
213
+ aria-valuenow={Math.round(progressPct)}
214
+ style={{
215
+ flex: 1,
216
+ height: 4,
217
+ background: "#e5e5e5",
218
+ borderRadius: 999,
219
+ position: "relative",
220
+ cursor: "pointer",
221
+ touchAction: "none",
222
+ }}
223
+ >
224
+ <div
225
+ style={{
226
+ position: "absolute",
227
+ inset: 0,
228
+ width: `${progressPct}%`,
229
+ background: accent,
230
+ borderRadius: 999,
231
+ }}
232
+ />
233
+ <div
234
+ style={{
235
+ position: "absolute",
236
+ top: "50%",
237
+ left: `${progressPct}%`,
238
+ width: 10,
239
+ height: 10,
240
+ marginTop: -5,
241
+ marginLeft: -5,
242
+ borderRadius: "50%",
243
+ background: "#fff",
244
+ boxShadow: `0 0 0 2px ${accent}`,
245
+ }}
246
+ />
247
+ </div>
248
+ <span style={{ fontSize: 11, color: "#777", fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap" }}>
249
+ {formatTime(currentTime)} / {formatTime(duration)}
250
+ </span>
251
+ <button
252
+ type="button"
253
+ onClick={() => setMuted((m) => !m)}
254
+ aria-label={muted ? "Unmute" : "Mute"}
255
+ style={{
256
+ width: 24,
257
+ height: 24,
258
+ flexShrink: 0,
259
+ border: "none",
260
+ background: "transparent",
261
+ color: "#777",
262
+ cursor: "pointer",
263
+ padding: 0,
264
+ display: "flex",
265
+ alignItems: "center",
266
+ justifyContent: "center",
267
+ }}
268
+ >
269
+ {muted ? (
270
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
271
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
272
+ <line x1="23" y1="9" x2="17" y2="15" />
273
+ <line x1="17" y1="9" x2="23" y2="15" />
274
+ </svg>
275
+ ) : (
276
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
277
+ <polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
278
+ <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
279
+ </svg>
280
+ )}
281
+ </button>
282
+ </div>
283
+ </div>
284
+ </div>
285
+ );
286
+ }
@@ -0,0 +1,274 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import type { BeforeAfterBlock } from "../../lib/sanity/types";
5
+ import { useAssetUrl } from "../../lib/contexts/AssetContext";
6
+ import { handleImageRetry, handleVideoRetry } from "../../lib/asset-retry";
7
+
8
+ /**
9
+ * BeforeAfterBlockRenderer — drag-slider comparator between two media assets.
10
+ *
11
+ * Supports image↔image, image↔video, video↔image, video↔video.
12
+ * Orientation is horizontal (left=before, right=after) or vertical
13
+ * (top=before, bottom=after). The "after" side is the one revealed as the
14
+ * user drags — clipped to the percentage the handle sits at.
15
+ *
16
+ * Interaction: drag-only (pointer events, works with touch via pointerType).
17
+ * Keyboard: left/right (or up/down in vertical mode) nudge the handle by 2%,
18
+ * shift+arrow by 10%. Home/End jump to 0/100.
19
+ */
20
+
21
+ const widthStyleMap: Record<string, { width: string; margin?: string }> = {
22
+ full: { width: "100%" },
23
+ contained: { width: "75%", margin: "0 auto" },
24
+ small: { width: "50%", margin: "0 auto" },
25
+ };
26
+
27
+ const aspectMap: Record<string, string | undefined> = {
28
+ auto: undefined,
29
+ "16:9": "16/9",
30
+ "4:3": "4/3",
31
+ "1:1": "1/1",
32
+ "21:9": "21/9",
33
+ };
34
+
35
+ function clamp(n: number, lo = 0, hi = 100): number {
36
+ return Math.max(lo, Math.min(hi, n));
37
+ }
38
+
39
+ function BeforeAfterMedia({
40
+ type,
41
+ src,
42
+ alt,
43
+ autoplay,
44
+ loop,
45
+ muted,
46
+ }: {
47
+ type: "image" | "video";
48
+ src: string;
49
+ alt: string;
50
+ autoplay: boolean;
51
+ loop: boolean;
52
+ muted: boolean;
53
+ }) {
54
+ const commonStyle: React.CSSProperties = {
55
+ position: "absolute",
56
+ inset: 0,
57
+ width: "100%",
58
+ height: "100%",
59
+ objectFit: "cover",
60
+ display: "block",
61
+ userSelect: "none",
62
+ pointerEvents: "none",
63
+ };
64
+ if (type === "video") {
65
+ return (
66
+ <video
67
+ src={src}
68
+ autoPlay={autoplay}
69
+ loop={loop}
70
+ muted={muted}
71
+ playsInline
72
+ preload={autoplay ? "auto" : "metadata"}
73
+ onError={handleVideoRetry}
74
+ style={commonStyle}
75
+ />
76
+ );
77
+ }
78
+ return (
79
+ /* eslint-disable-next-line @next/next/no-img-element */
80
+ <img
81
+ src={src}
82
+ alt={alt}
83
+ loading="lazy"
84
+ decoding="async"
85
+ draggable={false}
86
+ onError={handleImageRetry}
87
+ style={commonStyle}
88
+ />
89
+ );
90
+ }
91
+
92
+ export default function BeforeAfterBlockRenderer({ block }: { block: BeforeAfterBlock }) {
93
+ const resolveAsset = useAssetUrl();
94
+
95
+ const beforeType = block.before_media_type ?? "image";
96
+ const afterType = block.after_media_type ?? "image";
97
+ const orientation = block.orientation ?? "horizontal";
98
+ const initial = clamp(block.initial_position ?? 50);
99
+ const handleColor = block.handle_color || "#FFFFFF";
100
+
101
+ const beforeSrc = resolveAsset(block.before_asset_path);
102
+ const afterSrc = resolveAsset(block.after_asset_path);
103
+
104
+ const isFill = block.width === "fill";
105
+ const widthStyle = isFill ? {} : (widthStyleMap[block.width ?? "full"] || widthStyleMap.full);
106
+ const aspect = isFill ? undefined : aspectMap[block.aspect_ratio ?? "16:9"] ?? "16/9";
107
+
108
+ const rawRadius = block.border_radius ? String(block.border_radius).replace(/[a-z%]+$/i, "") : "";
109
+ const borderRadius = rawRadius && !isNaN(Number(rawRadius)) ? `${rawRadius}px` : undefined;
110
+
111
+ const containerRef = useRef<HTMLDivElement | null>(null);
112
+ const [position, setPosition] = useState<number>(initial);
113
+ const draggingRef = useRef(false);
114
+
115
+ const autoplay = block.video_autoplay !== false;
116
+ const loop = block.video_loop !== false;
117
+ const muted = block.video_muted !== false;
118
+
119
+ const updateFromEvent = useCallback((clientX: number, clientY: number) => {
120
+ const el = containerRef.current;
121
+ if (!el) return;
122
+ const rect = el.getBoundingClientRect();
123
+ const pct = orientation === "horizontal"
124
+ ? ((clientX - rect.left) / rect.width) * 100
125
+ : ((clientY - rect.top) / rect.height) * 100;
126
+ setPosition(clamp(pct));
127
+ }, [orientation]);
128
+
129
+ const onPointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
130
+ draggingRef.current = true;
131
+ (e.target as Element).setPointerCapture?.(e.pointerId);
132
+ updateFromEvent(e.clientX, e.clientY);
133
+ }, [updateFromEvent]);
134
+
135
+ const onPointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
136
+ if (!draggingRef.current) return;
137
+ updateFromEvent(e.clientX, e.clientY);
138
+ }, [updateFromEvent]);
139
+
140
+ const onPointerUp = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
141
+ draggingRef.current = false;
142
+ (e.target as Element).releasePointerCapture?.(e.pointerId);
143
+ }, []);
144
+
145
+ const onKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
146
+ const step = e.shiftKey ? 10 : 2;
147
+ const isHoriz = orientation === "horizontal";
148
+ if ((isHoriz && e.key === "ArrowLeft") || (!isHoriz && e.key === "ArrowUp")) {
149
+ e.preventDefault();
150
+ setPosition((p) => clamp(p - step));
151
+ } else if ((isHoriz && e.key === "ArrowRight") || (!isHoriz && e.key === "ArrowDown")) {
152
+ e.preventDefault();
153
+ setPosition((p) => clamp(p + step));
154
+ } else if (e.key === "Home") {
155
+ e.preventDefault();
156
+ setPosition(0);
157
+ } else if (e.key === "End") {
158
+ e.preventDefault();
159
+ setPosition(100);
160
+ }
161
+ }, [orientation]);
162
+
163
+ useEffect(() => {
164
+ setPosition(initial);
165
+ }, [initial]);
166
+
167
+ const afterClip = orientation === "horizontal"
168
+ ? `inset(0 0 0 ${position}%)`
169
+ : `inset(${position}% 0 0 0)`;
170
+
171
+ const handleWrapperStyle: React.CSSProperties = orientation === "horizontal"
172
+ ? {
173
+ position: "absolute",
174
+ top: 0, bottom: 0,
175
+ left: `${position}%`,
176
+ width: 2,
177
+ transform: "translateX(-1px)",
178
+ background: handleColor,
179
+ pointerEvents: "none",
180
+ }
181
+ : {
182
+ position: "absolute",
183
+ left: 0, right: 0,
184
+ top: `${position}%`,
185
+ height: 2,
186
+ transform: "translateY(-1px)",
187
+ background: handleColor,
188
+ pointerEvents: "none",
189
+ };
190
+
191
+ const knobStyle: React.CSSProperties = {
192
+ position: "absolute",
193
+ left: "50%",
194
+ top: "50%",
195
+ transform: "translate(-50%, -50%)",
196
+ width: 40,
197
+ height: 40,
198
+ borderRadius: "50%",
199
+ background: handleColor,
200
+ boxShadow: "0 2px 8px rgba(0,0,0,0.35)",
201
+ display: "flex",
202
+ alignItems: "center",
203
+ justifyContent: "center",
204
+ pointerEvents: "none",
205
+ };
206
+
207
+ const frameStyle: React.CSSProperties = isFill
208
+ ? { position: "absolute", inset: 0, borderRadius, overflow: "hidden", touchAction: "none", cursor: orientation === "horizontal" ? "ew-resize" : "ns-resize" }
209
+ : { position: "relative", ...widthStyle, aspectRatio: aspect, borderRadius, overflow: "hidden", touchAction: "none", cursor: orientation === "horizontal" ? "ew-resize" : "ns-resize" };
210
+
211
+ const shadowClass = block.shadow ? "shadow-lg" : "";
212
+
213
+ // Arrow icon inside the knob — points along the slider axis
214
+ const ArrowIcon = orientation === "horizontal" ? (
215
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
216
+ <path d="M5 4 L1 9 L5 14" stroke="#111" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
217
+ <path d="M13 4 L17 9 L13 14" stroke="#111" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
218
+ </svg>
219
+ ) : (
220
+ <svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
221
+ <path d="M4 5 L9 1 L14 5" stroke="#111" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
222
+ <path d="M4 13 L9 17 L14 13" stroke="#111" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
223
+ </svg>
224
+ );
225
+
226
+ return (
227
+ <div
228
+ ref={containerRef}
229
+ role="slider"
230
+ aria-label="Before / after comparison"
231
+ aria-valuemin={0}
232
+ aria-valuemax={100}
233
+ aria-valuenow={Math.round(position)}
234
+ aria-orientation={orientation}
235
+ tabIndex={0}
236
+ onPointerDown={onPointerDown}
237
+ onPointerMove={onPointerMove}
238
+ onPointerUp={onPointerUp}
239
+ onPointerCancel={onPointerUp}
240
+ onKeyDown={onKeyDown}
241
+ className={shadowClass}
242
+ style={frameStyle}
243
+ >
244
+ {/* Before layer — full frame */}
245
+ <div style={{ position: "absolute", inset: 0 }}>
246
+ <BeforeAfterMedia
247
+ type={beforeType}
248
+ src={beforeSrc}
249
+ alt={block.before_alt || ""}
250
+ autoplay={autoplay}
251
+ loop={loop}
252
+ muted={muted}
253
+ />
254
+ </div>
255
+
256
+ {/* After layer — clipped by slider position */}
257
+ <div style={{ position: "absolute", inset: 0, clipPath: afterClip, WebkitClipPath: afterClip }}>
258
+ <BeforeAfterMedia
259
+ type={afterType}
260
+ src={afterSrc}
261
+ alt={block.after_alt || ""}
262
+ autoplay={autoplay}
263
+ loop={loop}
264
+ muted={muted}
265
+ />
266
+ </div>
267
+
268
+ {/* Divider line + knob */}
269
+ <div style={handleWrapperStyle}>
270
+ <div style={knobStyle}>{ArrowIcon}</div>
271
+ </div>
272
+ </div>
273
+ );
274
+ }
@@ -214,6 +214,93 @@ export function ButtonBlockCardIcon() {
214
214
  );
215
215
  }
216
216
 
217
+ // ─────────────────────────────────────────────────────────────────────
218
+ // Before / After
219
+ // ─────────────────────────────────────────────────────────────────────
220
+ export function BeforeAfterBlockCardIcon() {
221
+ return (
222
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 120" width="100%" height="100%" preserveAspectRatio="xMidYMid meet">
223
+ <defs>
224
+ <BgDefs prefix="baBlk" />
225
+ <ShadowFilter id="shadBABlk" />
226
+ <VertBevel id="bevelBABlk" />
227
+ </defs>
228
+ <Bg prefix="baBlk" />
229
+
230
+ {/* 3D frame body with vertical bevel + shadow */}
231
+ <g filter="url(#shadBABlk)">
232
+ <path d="M26.8,19h112.7c1.3,0,2.3,1.2,2.3,2.7v75c0,1.5-1.1,2.7-2.3,2.7H26.8c-1.3-0.1-2.3-1.3-2.3-2.7V21.6C24.5,20.2,25.5,19,26.8,19z"
233
+ fill="url(#bevelBABlk)" stroke="#C9D3E4" strokeWidth="0.7" />
234
+ </g>
235
+
236
+ {/* Inner image-block background (right side — "after") */}
237
+ <path d="M31.5,25.3h103.4c0.7,0,1.4,0.7,1.4,1.6v64.4c0,0.9-0.6,1.6-1.4,1.6H31.5c-0.7,0-1.4-0.7-1.4-1.6V26.9C30.1,25.9,30.7,25.3,31.5,25.3z"
238
+ fill="#DDE6F5" stroke="#C9D3E4" strokeWidth="0.5" />
239
+
240
+ {/* Sun + mountain silhouette (composes the "after" image) */}
241
+ <ellipse cx="116.3" cy="41.3" rx="5.1" ry="5.1" fill="#FFFFFF" stroke="#4794E2" strokeWidth="2" />
242
+ <path d="M37.1,79.6l20.3-23c1.4-1.5,3.7-1.6,5.2-0.1l11.6,11.7c1.4,1.4,3.6,1.4,5,0L89.5,58c1.2-1.2,3.1-1.4,4.5-0.4l31,21.5c2.9,1.9,1.5,6.5-2,6.5H39.7C36.7,85.5,35.1,81.9,37.1,79.6z"
243
+ fill="#FFFFFF" stroke="#4794E2" strokeWidth="2" strokeMiterlimit="10" />
244
+
245
+ {/* Dashed frame outline */}
246
+ <path d="M26.8,19h112.7c1.3,0,2.3,1.2,2.3,2.7v75c0,1.5-1.1,2.7-2.3,2.7H26.8c-1.3-0.1-2.3-1.3-2.3-2.7V21.6C24.5,20.2,25.5,19,26.8,19z"
247
+ fill="none" stroke="#4794E2" strokeWidth="2" strokeDasharray="3,3" />
248
+
249
+ {/* "Before" half — gray panel clipped by the diagonal slider */}
250
+ <path d="M101.6,25.3H31.5c-0.8,0-1.4,0.6-1.4,1.6v64.4c0,0.9,0.7,1.6,1.4,1.6h30.4L101.6,25.3z"
251
+ fill="#E0E0E0" stroke="#D8D8D8" strokeWidth="0.5" />
252
+
253
+ {/* Before-side mountain silhouette */}
254
+ <path d="M75.9,69.1c-0.6-0.2-1.2-0.5-1.7-0.9L62.6,56.5c-1.5-1.5-3.8-1.4-5.2,0.1l-20.3,23c-2,2.3-0.4,5.9,2.6,6h26.5L75.9,69.1z"
255
+ fill="#FFFFFF" stroke="#B2B2B2" strokeWidth="2" strokeMiterlimit="10" />
256
+
257
+ {/* Diagonal slider line */}
258
+ <line x1="111" y1="8.4" x2="51.6" y2="110.1" stroke="#4794E2" strokeWidth="3" strokeLinecap="round" strokeMiterlimit="10" fill="none" />
259
+ </svg>
260
+ );
261
+ }
262
+
263
+ // ─────────────────────────────────────────────────────────────────────
264
+ // Audio
265
+ // ─────────────────────────────────────────────────────────────────────
266
+ export function AudioBlockCardIcon() {
267
+ return (
268
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 220 120" width="100%" height="100%" preserveAspectRatio="xMidYMid meet">
269
+ <defs>
270
+ <BgDefs prefix="aBlk" />
271
+ <ShadowFilter id="shadABlk" />
272
+ <VertBevel id="bevelABlk" />
273
+ </defs>
274
+ <Bg prefix="aBlk" />
275
+
276
+ {/* Pill-shaped frame body with vertical bevel + shadow */}
277
+ <g filter="url(#shadABlk)">
278
+ <path d="M120.1,84.3H46.9c-14.2,0-25.6-11.5-25.6-25.6v0c0-14.2,11.5-25.6,25.6-25.6h73.2c14.2,0,25.6,11.5,25.6,25.6v0C145.7,72.8,134.2,84.3,120.1,84.3z"
279
+ fill="url(#bevelABlk)" stroke="#C9D3E4" strokeWidth="0.7" />
280
+ </g>
281
+
282
+ {/* Inner pill panel */}
283
+ <path d="M120.3,79.3H46.6C35.2,79.3,26,70.1,26,58.7v0c0-11.4,9.2-20.6,20.6-20.6h73.6c11.4,0,20.6,9.2,20.6,20.6v0C140.8,70.1,131.6,79.3,120.3,79.3z"
284
+ fill="#DDE6F5" stroke="#C9D3E4" strokeWidth="0.5" />
285
+
286
+ {/* Waveform bars — positions from block_audio.svg mockup (tall, short, short, tall, short) */}
287
+ <path d="M73.9,43.1c-1.1,0-1.9,0.9-1.9,1.9v27.2c0,1.1,0.9,1.9,1.9,1.9c1.1,0,1.9-0.9,1.9-1.9V45.1C75.8,44,74.9,43.1,73.9,43.1z" fill="#4794E2" />
288
+ <path d="M86.9,49.2c-1.1,0-1.9,0.9-1.9,1.9v15.2c0,1.1,0.9,1.9,1.9,1.9c1.1,0,1.9-0.9,1.9-1.9V51.1C88.9,50,88,49.2,86.9,49.2z" fill="#4794E2" />
289
+ <path d="M98.3,49.2c-1.1,0-1.9,0.9-1.9,1.9v15.2c0,1.1,0.9,1.9,1.9,1.9c1.1,0,1.9-0.9,1.9-1.9V51.1C100.2,50,99.3,49.2,98.3,49.2z" fill="#4794E2" />
290
+ <path d="M109.5,43.1c-1.1,0-1.9,0.9-1.9,1.9v27.2c0,1.1,0.9,1.9,1.9,1.9c1.1,0,1.9-0.9,1.9-1.9V45.1C111.4,44,110.5,43.1,109.5,43.1z" fill="#4794E2" />
291
+ <path d="M121.1,49.2c-1.1,0-1.9,0.9-1.9,1.9v15.2c0,1.1,0.9,1.9,1.9,1.9c1.1,0,1.9-0.9,1.9-1.9V51.1C123,50,122.2,49.2,121.1,49.2z" fill="#4794E2" />
292
+
293
+ {/* Play button — rounded triangle */}
294
+ <path d="M42.3,59.6v-7.7c0-1.7,1.9-2.8,3.4-2l6.7,3.8l6.7,3.8c1.5,0.9,1.5,3.1,0,3.9l-6.7,3.8l-6.7,3.8c-1.5,0.9-3.4-0.2-3.4-2V59.6z"
295
+ fill="#4794E2" />
296
+
297
+ {/* Dashed frame outline — matches the pill frame */}
298
+ <path d="M120.1,84.3H46.9c-14.2,0-25.6-11.5-25.6-25.6v0c0-14.2,11.5-25.6,25.6-25.6h73.2c14.2,0,25.6,11.5,25.6,25.6v0C145.7,72.8,134.2,84.3,120.1,84.3z"
299
+ fill="none" stroke="#4794E2" strokeWidth="2" strokeDasharray="3,3" />
300
+ </svg>
301
+ );
302
+ }
303
+
217
304
  // ─────────────────────────────────────────────────────────────────────
218
305
  // Lookup map
219
306
  // ─────────────────────────────────────────────────────────────────────
@@ -224,4 +311,6 @@ export const BLOCK_CARD_ICONS: Record<string, React.FC> = {
224
311
  videoBlock: VideoBlockCardIcon,
225
312
  spacerBlock: SpacerBlockCardIcon,
226
313
  buttonBlock: ButtonBlockCardIcon,
314
+ beforeAfterBlock: BeforeAfterBlockCardIcon,
315
+ audioBlock: AudioBlockCardIcon,
227
316
  };
@@ -19,6 +19,8 @@ const BLOCK_LABELS: Record<string, { label: string; description: string }> = {
19
19
  videoBlock: { label: "Video", description: "Vimeo, YouTube or MP4 file" },
20
20
  spacerBlock: { label: "Spacer", description: "Customizable vertical spacing" },
21
21
  buttonBlock: { label: "Button", description: "Call-to-action button (CTA)" },
22
+ beforeAfterBlock: { label: "Before / After", description: "Drag-slider comparison between two images or videos" },
23
+ audioBlock: { label: "Audio", description: "Minimal audio player with cover art and metadata" },
22
24
  projectGridBlock: { label: "Project Grid", description: "Staggered project showcase grid" },
23
25
  };
24
26
 
@@ -32,6 +32,11 @@ export function ColumnDragProvider({ children }: ColumnDragProviderProps) {
32
32
  sectionKey={columnDrag.draggedSectionKey}
33
33
  columnKey={columnDrag.draggedColumnKey}
34
34
  position={columnDrag.overlayPosition}
35
+ // If hovering over a target, use its validity; if no target
36
+ // (empty space), stay in default (valid) state.
37
+ isValidDrop={
38
+ columnDrag.dropTarget ? columnDrag.dropTarget.isValid : true
39
+ }
35
40
  />
36
41
  )}
37
42
  </ColumnDragContext.Provider>