@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
|
@@ -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>
|