@morphika/andami 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/CoverSectionCanvas.tsx +90 -2
- package/components/builder/SectionV2Canvas.tsx +19 -3
- 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/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/types.ts +2 -0
- package/lib/sanity/types.ts +58 -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,360 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useBuilderStore } from "../../../lib/builder/store";
|
|
4
|
+
import { getEffectiveValue, setResponsiveOverride } from "../../../lib/builder/responsive";
|
|
5
|
+
import type { BeforeAfterBlock, ContentBlock } from "../../../lib/sanity/types";
|
|
6
|
+
import ColorSwatchPicker, { usePaletteSwatches } from "../ColorSwatchPicker";
|
|
7
|
+
import { resolveColorHex } from "../../../lib/color-utils";
|
|
8
|
+
import {
|
|
9
|
+
SourceIcon,
|
|
10
|
+
LayoutIcon,
|
|
11
|
+
AppearanceIcon,
|
|
12
|
+
PlaybackIcon,
|
|
13
|
+
OptionsIcon,
|
|
14
|
+
} from "./section-icons";
|
|
15
|
+
import {
|
|
16
|
+
SettingsField,
|
|
17
|
+
SettingsSection,
|
|
18
|
+
StyledCheckbox,
|
|
19
|
+
AssetPathInput,
|
|
20
|
+
ViewportBadge,
|
|
21
|
+
ResponsiveField,
|
|
22
|
+
useActiveViewport,
|
|
23
|
+
INPUT_CLASS,
|
|
24
|
+
SELECT_CLASS,
|
|
25
|
+
} from "./shared";
|
|
26
|
+
|
|
27
|
+
interface Props {
|
|
28
|
+
block: BeforeAfterBlock;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default function BeforeAfterBlockEditor({ block }: Props) {
|
|
32
|
+
const store = useBuilderStore();
|
|
33
|
+
const viewport = useActiveViewport();
|
|
34
|
+
const paletteSwatches = usePaletteSwatches();
|
|
35
|
+
|
|
36
|
+
const snapshotOnFocus = () => store._pushSnapshot();
|
|
37
|
+
|
|
38
|
+
const updateResponsive = (property: string, value: unknown) => {
|
|
39
|
+
if (viewport === "desktop") {
|
|
40
|
+
store.updateBlock(block._key, { [property]: value } as Partial<ContentBlock>);
|
|
41
|
+
} else {
|
|
42
|
+
const overrides = setResponsiveOverride(block as ContentBlock, viewport, property, value);
|
|
43
|
+
store.updateBlock(block._key, overrides as Partial<ContentBlock>);
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const resetOverride = (property: string) => {
|
|
48
|
+
const overrides = setResponsiveOverride(block as ContentBlock, viewport, property, undefined);
|
|
49
|
+
store.updateBlock(block._key, overrides as Partial<ContentBlock>);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const update = (updates: Partial<BeforeAfterBlock>) => {
|
|
53
|
+
store.updateBlock(block._key, updates as Partial<ContentBlock>);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const updateDebounced = (updates: Partial<BeforeAfterBlock>) => {
|
|
57
|
+
store.updateBlockDebounced(block._key, updates as Partial<ContentBlock>);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const beforeType = block.before_media_type || "image";
|
|
61
|
+
const afterType = block.after_media_type || "image";
|
|
62
|
+
|
|
63
|
+
const effectiveWidth = getEffectiveValue<string>(
|
|
64
|
+
block as ContentBlock, viewport, "width", block.width || "full"
|
|
65
|
+
);
|
|
66
|
+
const effectiveAspect = getEffectiveValue<string>(
|
|
67
|
+
block as ContentBlock, viewport, "aspect_ratio", block.aspect_ratio || "16:9"
|
|
68
|
+
);
|
|
69
|
+
const effectiveOrientation = getEffectiveValue<string>(
|
|
70
|
+
block as ContentBlock, viewport, "orientation", block.orientation || "horizontal"
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const eitherIsVideo = beforeType === "video" || afterType === "video";
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<>
|
|
77
|
+
<ViewportBadge />
|
|
78
|
+
|
|
79
|
+
{/* ── Before source ── */}
|
|
80
|
+
<SettingsSection title="Before" defaultOpen icon={<SourceIcon />}>
|
|
81
|
+
<SettingsField label="Media Type">
|
|
82
|
+
<div className="flex gap-1">
|
|
83
|
+
{(
|
|
84
|
+
[
|
|
85
|
+
{ value: "image", label: "Image" },
|
|
86
|
+
{ value: "video", label: "Video" },
|
|
87
|
+
] as const
|
|
88
|
+
).map((opt) => (
|
|
89
|
+
<button
|
|
90
|
+
key={opt.value}
|
|
91
|
+
onClick={() => update({ before_media_type: opt.value })}
|
|
92
|
+
className={`flex-1 rounded border py-1 text-xs transition-colors ${
|
|
93
|
+
beforeType === opt.value
|
|
94
|
+
? "border-[#076bff] bg-[#076bff]/20 text-neutral-900"
|
|
95
|
+
: "border-neutral-200 bg-white text-neutral-500 hover:border-neutral-600"
|
|
96
|
+
}`}
|
|
97
|
+
>
|
|
98
|
+
{opt.label}
|
|
99
|
+
</button>
|
|
100
|
+
))}
|
|
101
|
+
</div>
|
|
102
|
+
</SettingsField>
|
|
103
|
+
|
|
104
|
+
<SettingsField label="Asset Path" hint="Relative path from seed URL">
|
|
105
|
+
<AssetPathInput
|
|
106
|
+
value={block.before_asset_path || ""}
|
|
107
|
+
onFocus={snapshotOnFocus}
|
|
108
|
+
onChange={(v) => updateDebounced({ before_asset_path: v })}
|
|
109
|
+
placeholder={beforeType === "video" ? "projects/slug/before.mp4" : "projects/slug/before.jpg"}
|
|
110
|
+
filterType={beforeType}
|
|
111
|
+
/>
|
|
112
|
+
</SettingsField>
|
|
113
|
+
|
|
114
|
+
<SettingsField label="Alt Text">
|
|
115
|
+
<input
|
|
116
|
+
type="text"
|
|
117
|
+
value={block.before_alt || ""}
|
|
118
|
+
onFocus={snapshotOnFocus}
|
|
119
|
+
onChange={(e) => updateDebounced({ before_alt: e.target.value })}
|
|
120
|
+
className={INPUT_CLASS}
|
|
121
|
+
placeholder="Describe the before media"
|
|
122
|
+
/>
|
|
123
|
+
</SettingsField>
|
|
124
|
+
</SettingsSection>
|
|
125
|
+
|
|
126
|
+
{/* ── After source ── */}
|
|
127
|
+
<SettingsSection title="After" icon={<SourceIcon />}>
|
|
128
|
+
<SettingsField label="Media Type">
|
|
129
|
+
<div className="flex gap-1">
|
|
130
|
+
{(
|
|
131
|
+
[
|
|
132
|
+
{ value: "image", label: "Image" },
|
|
133
|
+
{ value: "video", label: "Video" },
|
|
134
|
+
] as const
|
|
135
|
+
).map((opt) => (
|
|
136
|
+
<button
|
|
137
|
+
key={opt.value}
|
|
138
|
+
onClick={() => update({ after_media_type: opt.value })}
|
|
139
|
+
className={`flex-1 rounded border py-1 text-xs transition-colors ${
|
|
140
|
+
afterType === opt.value
|
|
141
|
+
? "border-[#076bff] bg-[#076bff]/20 text-neutral-900"
|
|
142
|
+
: "border-neutral-200 bg-white text-neutral-500 hover:border-neutral-600"
|
|
143
|
+
}`}
|
|
144
|
+
>
|
|
145
|
+
{opt.label}
|
|
146
|
+
</button>
|
|
147
|
+
))}
|
|
148
|
+
</div>
|
|
149
|
+
</SettingsField>
|
|
150
|
+
|
|
151
|
+
<SettingsField label="Asset Path" hint="Relative path from seed URL">
|
|
152
|
+
<AssetPathInput
|
|
153
|
+
value={block.after_asset_path || ""}
|
|
154
|
+
onFocus={snapshotOnFocus}
|
|
155
|
+
onChange={(v) => updateDebounced({ after_asset_path: v })}
|
|
156
|
+
placeholder={afterType === "video" ? "projects/slug/after.mp4" : "projects/slug/after.jpg"}
|
|
157
|
+
filterType={afterType}
|
|
158
|
+
/>
|
|
159
|
+
</SettingsField>
|
|
160
|
+
|
|
161
|
+
<SettingsField label="Alt Text">
|
|
162
|
+
<input
|
|
163
|
+
type="text"
|
|
164
|
+
value={block.after_alt || ""}
|
|
165
|
+
onFocus={snapshotOnFocus}
|
|
166
|
+
onChange={(e) => updateDebounced({ after_alt: e.target.value })}
|
|
167
|
+
className={INPUT_CLASS}
|
|
168
|
+
placeholder="Describe the after media"
|
|
169
|
+
/>
|
|
170
|
+
</SettingsField>
|
|
171
|
+
</SettingsSection>
|
|
172
|
+
|
|
173
|
+
{/* ── Slider options ── */}
|
|
174
|
+
<SettingsSection title="Slider" icon={<OptionsIcon />}>
|
|
175
|
+
<ResponsiveField
|
|
176
|
+
label="Orientation"
|
|
177
|
+
block={block as ContentBlock}
|
|
178
|
+
property="orientation"
|
|
179
|
+
onReset={() => resetOverride("orientation")}
|
|
180
|
+
>
|
|
181
|
+
<div className="flex gap-1">
|
|
182
|
+
{(
|
|
183
|
+
[
|
|
184
|
+
{ value: "horizontal", label: "Horizontal" },
|
|
185
|
+
{ value: "vertical", label: "Vertical" },
|
|
186
|
+
] as const
|
|
187
|
+
).map((opt) => (
|
|
188
|
+
<button
|
|
189
|
+
key={opt.value}
|
|
190
|
+
onClick={() => updateResponsive("orientation", opt.value)}
|
|
191
|
+
className={`flex-1 rounded border py-1 text-xs transition-colors ${
|
|
192
|
+
effectiveOrientation === opt.value
|
|
193
|
+
? "border-[#076bff] bg-[#076bff]/20 text-neutral-900"
|
|
194
|
+
: "border-neutral-200 bg-white text-neutral-500 hover:border-neutral-600"
|
|
195
|
+
}`}
|
|
196
|
+
>
|
|
197
|
+
{opt.label}
|
|
198
|
+
</button>
|
|
199
|
+
))}
|
|
200
|
+
</div>
|
|
201
|
+
</ResponsiveField>
|
|
202
|
+
|
|
203
|
+
<SettingsField label="Initial Position" hint="0–100%">
|
|
204
|
+
<div className="flex items-center gap-1.5">
|
|
205
|
+
<input
|
|
206
|
+
type="number"
|
|
207
|
+
min={0}
|
|
208
|
+
max={100}
|
|
209
|
+
value={block.initial_position ?? 50}
|
|
210
|
+
onFocus={snapshotOnFocus}
|
|
211
|
+
onChange={(e) => {
|
|
212
|
+
const n = Math.max(0, Math.min(100, Number(e.target.value) || 0));
|
|
213
|
+
updateDebounced({ initial_position: n });
|
|
214
|
+
}}
|
|
215
|
+
className={INPUT_CLASS}
|
|
216
|
+
placeholder="50"
|
|
217
|
+
/>
|
|
218
|
+
<span className="text-[10px] text-neutral-400 shrink-0">%</span>
|
|
219
|
+
</div>
|
|
220
|
+
</SettingsField>
|
|
221
|
+
|
|
222
|
+
<SettingsField label="Handle Color">
|
|
223
|
+
<ColorSwatchPicker
|
|
224
|
+
value={block.handle_color || "#FFFFFF"}
|
|
225
|
+
onChange={(value) => {
|
|
226
|
+
const hex = resolveColorHex(value) || "#FFFFFF";
|
|
227
|
+
update({ handle_color: hex });
|
|
228
|
+
}}
|
|
229
|
+
swatches={paletteSwatches}
|
|
230
|
+
/>
|
|
231
|
+
</SettingsField>
|
|
232
|
+
</SettingsSection>
|
|
233
|
+
|
|
234
|
+
{/* ── Layout ── */}
|
|
235
|
+
<SettingsSection title="Layout" icon={<LayoutIcon />}>
|
|
236
|
+
<ResponsiveField
|
|
237
|
+
label="Width"
|
|
238
|
+
block={block as ContentBlock}
|
|
239
|
+
property="width"
|
|
240
|
+
onReset={() => resetOverride("width")}
|
|
241
|
+
>
|
|
242
|
+
<div className="flex gap-1">
|
|
243
|
+
{(
|
|
244
|
+
[
|
|
245
|
+
{ value: "full", label: "Full" },
|
|
246
|
+
{ value: "contained", label: "Contained" },
|
|
247
|
+
{ value: "small", label: "Small" },
|
|
248
|
+
{ value: "fill", label: "Fill" },
|
|
249
|
+
] as const
|
|
250
|
+
).map((opt) => (
|
|
251
|
+
<button
|
|
252
|
+
key={opt.value}
|
|
253
|
+
onClick={() => updateResponsive("width", opt.value)}
|
|
254
|
+
className={`flex-1 rounded border py-1 text-xs transition-colors ${
|
|
255
|
+
effectiveWidth === opt.value
|
|
256
|
+
? "border-[#076bff] bg-[#076bff]/20 text-neutral-900"
|
|
257
|
+
: "border-neutral-200 bg-white text-neutral-500 hover:border-neutral-600"
|
|
258
|
+
}`}
|
|
259
|
+
>
|
|
260
|
+
{opt.label}
|
|
261
|
+
</button>
|
|
262
|
+
))}
|
|
263
|
+
</div>
|
|
264
|
+
</ResponsiveField>
|
|
265
|
+
|
|
266
|
+
<ResponsiveField
|
|
267
|
+
label="Aspect Ratio"
|
|
268
|
+
block={block as ContentBlock}
|
|
269
|
+
property="aspect_ratio"
|
|
270
|
+
onReset={() => resetOverride("aspect_ratio")}
|
|
271
|
+
>
|
|
272
|
+
<select
|
|
273
|
+
value={effectiveAspect}
|
|
274
|
+
onChange={(e) => updateResponsive("aspect_ratio", e.target.value)}
|
|
275
|
+
className={SELECT_CLASS}
|
|
276
|
+
>
|
|
277
|
+
<option value="auto">Auto</option>
|
|
278
|
+
<option value="16:9">16:9</option>
|
|
279
|
+
<option value="4:3">4:3</option>
|
|
280
|
+
<option value="1:1">1:1</option>
|
|
281
|
+
<option value="21:9">21:9</option>
|
|
282
|
+
</select>
|
|
283
|
+
</ResponsiveField>
|
|
284
|
+
</SettingsSection>
|
|
285
|
+
|
|
286
|
+
{/* ── Appearance ── */}
|
|
287
|
+
<SettingsSection title="Appearance" icon={<AppearanceIcon />}>
|
|
288
|
+
<ResponsiveField
|
|
289
|
+
label="Border Radius"
|
|
290
|
+
block={block as ContentBlock}
|
|
291
|
+
property="border_radius"
|
|
292
|
+
onReset={() => resetOverride("border_radius")}
|
|
293
|
+
>
|
|
294
|
+
<div className="flex items-center gap-1.5">
|
|
295
|
+
<input
|
|
296
|
+
type="number"
|
|
297
|
+
value={String(getEffectiveValue<string>(block as ContentBlock, viewport, "border_radius", block.border_radius || "")).replace(/px$/i, "")}
|
|
298
|
+
onFocus={snapshotOnFocus}
|
|
299
|
+
onChange={(e) => {
|
|
300
|
+
store._pushSnapshot();
|
|
301
|
+
updateResponsive("border_radius", e.target.value.replace(/[^0-9]/g, ""));
|
|
302
|
+
}}
|
|
303
|
+
className={INPUT_CLASS}
|
|
304
|
+
placeholder="0"
|
|
305
|
+
min={0}
|
|
306
|
+
/>
|
|
307
|
+
<span className="text-[10px] text-neutral-400 shrink-0">px</span>
|
|
308
|
+
</div>
|
|
309
|
+
</ResponsiveField>
|
|
310
|
+
|
|
311
|
+
<ResponsiveField
|
|
312
|
+
label="Shadow"
|
|
313
|
+
block={block as ContentBlock}
|
|
314
|
+
property="shadow"
|
|
315
|
+
onReset={() => resetOverride("shadow")}
|
|
316
|
+
>
|
|
317
|
+
<button
|
|
318
|
+
type="button"
|
|
319
|
+
onClick={() => {
|
|
320
|
+
const effectiveShadow = getEffectiveValue<boolean>(block as ContentBlock, viewport, "shadow", block.shadow || false);
|
|
321
|
+
updateResponsive("shadow", !effectiveShadow);
|
|
322
|
+
}}
|
|
323
|
+
className={`relative w-8 h-[18px] rounded-full transition-colors ${
|
|
324
|
+
getEffectiveValue<boolean>(block as ContentBlock, viewport, "shadow", block.shadow || false) ? "bg-[#076bff]" : "bg-neutral-200 hover:bg-neutral-300"
|
|
325
|
+
}`}
|
|
326
|
+
>
|
|
327
|
+
<span
|
|
328
|
+
className={`absolute top-[2px] w-[14px] h-[14px] rounded-full bg-white shadow-sm transition-transform ${
|
|
329
|
+
getEffectiveValue<boolean>(block as ContentBlock, viewport, "shadow", block.shadow || false) ? "left-[16px]" : "left-[2px]"
|
|
330
|
+
}`}
|
|
331
|
+
/>
|
|
332
|
+
</button>
|
|
333
|
+
</ResponsiveField>
|
|
334
|
+
</SettingsSection>
|
|
335
|
+
|
|
336
|
+
{/* ── Video playback (only shown when at least one side is video) ── */}
|
|
337
|
+
{eitherIsVideo && (
|
|
338
|
+
<SettingsSection title="Playback" icon={<PlaybackIcon />}>
|
|
339
|
+
<div className="space-y-1.5">
|
|
340
|
+
<StyledCheckbox
|
|
341
|
+
label="Autoplay"
|
|
342
|
+
checked={block.video_autoplay !== false}
|
|
343
|
+
onChange={(checked) => update({ video_autoplay: checked })}
|
|
344
|
+
/>
|
|
345
|
+
<StyledCheckbox
|
|
346
|
+
label="Loop"
|
|
347
|
+
checked={block.video_loop !== false}
|
|
348
|
+
onChange={(checked) => update({ video_loop: checked })}
|
|
349
|
+
/>
|
|
350
|
+
<StyledCheckbox
|
|
351
|
+
label="Muted"
|
|
352
|
+
checked={block.video_muted !== false}
|
|
353
|
+
onChange={(checked) => update({ video_muted: checked })}
|
|
354
|
+
/>
|
|
355
|
+
</div>
|
|
356
|
+
</SettingsSection>
|
|
357
|
+
)}
|
|
358
|
+
</>
|
|
359
|
+
);
|
|
360
|
+
}
|
|
@@ -320,7 +320,7 @@ export function AssetPathInput({
|
|
|
320
320
|
onChange: (value: string) => void;
|
|
321
321
|
onFocus?: () => void;
|
|
322
322
|
placeholder?: string;
|
|
323
|
-
filterType?: "image" | "video" | "all";
|
|
323
|
+
filterType?: "image" | "video" | "audio" | "all";
|
|
324
324
|
}) {
|
|
325
325
|
const [browserOpen, setBrowserOpen] = useState(false);
|
|
326
326
|
|
|
@@ -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
|
+
}
|
|
@@ -73,6 +73,8 @@ export const BLOCK_ENTER_PRESETS: Record<BlockType, readonly EnterPreset[]> = {
|
|
|
73
73
|
imageGridBlock: ["fade", "scale", "slide-up"],
|
|
74
74
|
videoBlock: ["fade", "slide-up"],
|
|
75
75
|
buttonBlock: ["fade", "slide-up", "scale"],
|
|
76
|
+
beforeAfterBlock: ["fade", "slide-up", "scale"],
|
|
77
|
+
audioBlock: ["fade", "slide-up", "scale"],
|
|
76
78
|
spacerBlock: [], // invisible — no animation
|
|
77
79
|
projectGridBlock: [], // uses card_entrance system
|
|
78
80
|
projectCarouselBlock: [], // uses card_entrance system
|
|
@@ -66,6 +66,8 @@ export const BLOCK_HOVER_PRESETS: Record<BlockType, readonly HoverPreset[]> = {
|
|
|
66
66
|
imageGridBlock: ["tilt-3d"],
|
|
67
67
|
videoBlock: [], // video has play/pause interaction
|
|
68
68
|
buttonBlock: ["scale-up", "lift", "border-glow"],
|
|
69
|
+
beforeAfterBlock: [], // slider handles its own drag interaction
|
|
70
|
+
audioBlock: [], // audio has play/pause interaction
|
|
69
71
|
spacerBlock: [], // invisible
|
|
70
72
|
projectGridBlock: ["scale-up", "lift"], // per-card effect
|
|
71
73
|
projectCarouselBlock: [], // uses per-card hover_effect field directly
|