@morphika/andami 0.2.11 → 0.2.13

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 (49) hide show
  1. package/README.md +2 -1
  2. package/app/admin/pages/[slug]/page.tsx +39 -2
  3. package/components/blocks/BlockRenderer.tsx +0 -7
  4. package/components/blocks/CoverSectionRenderer.tsx +295 -0
  5. package/components/blocks/PageRenderer.tsx +13 -9
  6. package/components/builder/BlockLivePreview.tsx +0 -5
  7. package/components/builder/BlockTypePicker.tsx +0 -1
  8. package/components/builder/ColorSwatchPicker.tsx +2 -2
  9. package/components/builder/CoverRowResizeHandle.tsx +180 -0
  10. package/components/builder/CoverSectionCanvas.tsx +260 -0
  11. package/components/builder/ReadOnlyFrame.tsx +127 -3
  12. package/components/builder/SectionTypePicker.tsx +29 -0
  13. package/components/builder/SectionV2Canvas.tsx +4 -1
  14. package/components/builder/SectionV2Column.tsx +15 -20
  15. package/components/builder/SettingsPanel.tsx +14 -0
  16. package/components/builder/SortableRow.tsx +7 -21
  17. package/components/builder/blockStyles.tsx +13 -14
  18. package/components/builder/editors/index.ts +0 -1
  19. package/components/builder/index.ts +1 -0
  20. package/components/builder/live-preview/RichTextEditor.tsx +23 -2
  21. package/components/builder/live-preview/index.ts +0 -1
  22. package/components/builder/settings-panel/BlockSettings.tsx +0 -7
  23. package/components/builder/settings-panel/CoverSectionSettings.tsx +296 -0
  24. package/components/builder/settings-panel/index.ts +1 -0
  25. package/components/builder/settings-panel/useSettingsPanelSelection.ts +36 -2
  26. package/lib/animation/enter-types.ts +0 -1
  27. package/lib/animation/hover-effect-types.ts +0 -1
  28. package/lib/builder/defaults.ts +43 -22
  29. package/lib/builder/serializer/normalizers.ts +34 -1
  30. package/lib/builder/serializer/serializers.ts +39 -2
  31. package/lib/builder/store-blocks.ts +11 -3
  32. package/lib/builder/store-cover.ts +220 -0
  33. package/lib/builder/store-helpers.ts +81 -4
  34. package/lib/builder/store-sections.ts +12 -2
  35. package/lib/builder/store.ts +11 -2
  36. package/lib/builder/types.ts +15 -2
  37. package/lib/sanity/types.ts +79 -43
  38. package/lib/version.ts +1 -1
  39. package/package.json +1 -1
  40. package/sanity/schemas/blocks/index.ts +1 -2
  41. package/sanity/schemas/index.ts +5 -3
  42. package/sanity/schemas/objects/coverSection.ts +317 -0
  43. package/sanity/schemas/objects/parallaxSlide.ts +0 -1
  44. package/sanity/schemas/page.ts +1 -1
  45. package/sanity/schemas/pageSectionV2.ts +0 -1
  46. package/components/blocks/CoverBlockRenderer.tsx +0 -261
  47. package/components/builder/editors/CoverBlockEditor.tsx +0 -550
  48. package/components/builder/live-preview/LiveCoverPreview.tsx +0 -146
  49. package/sanity/schemas/blocks/coverBlock.ts +0 -229
@@ -1,550 +0,0 @@
1
- "use client";
2
-
3
- import { useBuilderStore } from "../../../lib/builder/store";
4
- import { getEffectiveValue, setResponsiveOverride } from "../../../lib/builder/responsive";
5
- import type { CoverBlock, ContentBlock } from "../../../lib/sanity/types";
6
- import {
7
- ContentIcon,
8
- CTAButtonIcon,
9
- CoverBackgroundIcon,
10
- CoverEffectsIcon,
11
- LayoutIcon,
12
- AppearanceIcon,
13
- } from "./section-icons";
14
- import {
15
- SettingsField,
16
- SettingsSection,
17
- StyledInput,
18
- StyledCheckbox,
19
- AssetPathInput,
20
- ViewportBadge,
21
- ResponsiveField,
22
- useActiveViewport,
23
- SELECT_CLASS,
24
- } from "./shared";
25
- import ColorSwatchPicker, { usePaletteSwatches } from "../ColorSwatchPicker";
26
- import { serializeColorField, parseColorField, isGradient } from "../../../lib/color-utils";
27
- import type { ColorField } from "../../../lib/sanity/types";
28
-
29
- interface Props {
30
- block: CoverBlock;
31
- }
32
-
33
- // ============================================
34
- // 9-point position grid (Semplice-style)
35
- // ============================================
36
-
37
- const POSITIONS = [
38
- { label: "↖", value: "top left" },
39
- { label: "↑", value: "top center" },
40
- { label: "↗", value: "top right" },
41
- { label: "←", value: "center left" },
42
- { label: "·", value: "center center" },
43
- { label: "→", value: "center right" },
44
- { label: "↙", value: "bottom left" },
45
- { label: "↓", value: "bottom center" },
46
- { label: "↘", value: "bottom right" },
47
- ];
48
-
49
- function PositionGrid({
50
- value,
51
- onChange,
52
- }: {
53
- value: string;
54
- onChange: (v: string) => void;
55
- }) {
56
- return (
57
- <div className="grid grid-cols-3 gap-0.5 w-[84px]">
58
- {POSITIONS.map((pos) => (
59
- <button
60
- key={pos.value}
61
- onClick={() => onChange(pos.value)}
62
- className={`w-7 h-7 rounded text-[10px] flex items-center justify-center transition-colors ${
63
- value === pos.value
64
- ? "bg-[#076bff] text-white"
65
- : "bg-neutral-100 text-neutral-400 hover:bg-neutral-200"
66
- }`}
67
- title={pos.value}
68
- >
69
- {pos.label}
70
- </button>
71
- ))}
72
- </div>
73
- );
74
- }
75
-
76
- // ============================================
77
- // 3x3 content alignment grid
78
- // ============================================
79
-
80
- function ContentAlignGrid({
81
- alignH,
82
- alignV,
83
- onChangeH,
84
- onChangeV,
85
- }: {
86
- alignH: string;
87
- alignV: string;
88
- onChangeH: (v: "left" | "center" | "right") => void;
89
- onChangeV: (v: "top" | "center" | "bottom") => void;
90
- }) {
91
- const vOptions: Array<{ label: string; value: "top" | "center" | "bottom" }> = [
92
- { label: "T", value: "top" },
93
- { label: "M", value: "center" },
94
- { label: "B", value: "bottom" },
95
- ];
96
- const hOptions: Array<{ label: string; value: "left" | "center" | "right" }> = [
97
- { label: "L", value: "left" },
98
- { label: "C", value: "center" },
99
- { label: "R", value: "right" },
100
- ];
101
-
102
- return (
103
- <div className="grid grid-cols-3 gap-0.5 w-[84px]">
104
- {vOptions.map((v) =>
105
- hOptions.map((h) => {
106
- const isActive = alignH === h.value && alignV === v.value;
107
- return (
108
- <button
109
- key={`${v.value}-${h.value}`}
110
- onClick={() => {
111
- onChangeH(h.value);
112
- onChangeV(v.value);
113
- }}
114
- className={`w-7 h-7 rounded flex items-center justify-center transition-colors ${
115
- isActive
116
- ? "bg-[#076bff] text-white"
117
- : "bg-neutral-100 text-neutral-400 hover:bg-neutral-200"
118
- }`}
119
- title={`${v.value} ${h.value}`}
120
- >
121
- <div
122
- className={`w-1.5 h-1.5 rounded-full ${
123
- isActive ? "bg-white" : "bg-neutral-400"
124
- }`}
125
- />
126
- </button>
127
- );
128
- })
129
- )}
130
- </div>
131
- );
132
- }
133
-
134
- // ============================================
135
- // Main Editor
136
- // ============================================
137
-
138
- export default function CoverBlockEditor({ block }: Props) {
139
- const store = useBuilderStore();
140
- const viewport = useActiveViewport();
141
- const paletteSwatches = usePaletteSwatches();
142
- const cta = block.cta_button || {};
143
-
144
- const snapshotOnFocus = () => store._pushSnapshot();
145
-
146
- // Responsive-aware update
147
- const updateResponsive = (property: string, value: unknown) => {
148
- if (viewport === "desktop") {
149
- store.updateBlock(block._key, { [property]: value } as Partial<ContentBlock>);
150
- } else {
151
- const overrides = setResponsiveOverride(block as ContentBlock, viewport, property, value);
152
- store.updateBlock(block._key, overrides as Partial<ContentBlock>);
153
- }
154
- };
155
-
156
- const updateResponsiveDebounced = (property: string, value: unknown) => {
157
- if (viewport === "desktop") {
158
- store.updateBlockDebounced(block._key, { [property]: value } as Partial<ContentBlock>);
159
- } else {
160
- const overrides = setResponsiveOverride(block as ContentBlock, viewport, property, value);
161
- store.updateBlockDebounced(block._key, overrides as Partial<ContentBlock>);
162
- }
163
- };
164
-
165
- const resetOverride = (property: string) => {
166
- const overrides = setResponsiveOverride(block as ContentBlock, viewport, property, undefined);
167
- store.updateBlock(block._key, overrides as Partial<ContentBlock>);
168
- };
169
-
170
- // Direct update (base block, not responsive)
171
- const update = (updates: Partial<CoverBlock>) => {
172
- store.updateBlock(block._key, updates as Partial<ContentBlock>);
173
- };
174
-
175
- const updateDebounced = (updates: Partial<CoverBlock>) => {
176
- store.updateBlockDebounced(block._key, updates as Partial<ContentBlock>);
177
- };
178
-
179
- // Effective values for the active viewport
180
- const effectiveHeight = getEffectiveValue<string>(
181
- block as ContentBlock, viewport, "height", block.height || "100vh"
182
- );
183
- const effectiveCustomHeight = getEffectiveValue<string>(
184
- block as ContentBlock, viewport, "custom_height", block.custom_height || ""
185
- );
186
- const effectiveAlignH = getEffectiveValue<string>(
187
- block as ContentBlock, viewport, "content_align_h", block.content_align_h || "center"
188
- );
189
- const effectiveAlignV = getEffectiveValue<string>(
190
- block as ContentBlock, viewport, "content_align_v", block.content_align_v || "center"
191
- );
192
- const effectiveMaxWidth = getEffectiveValue<string>(
193
- block as ContentBlock, viewport, "content_max_width", block.content_max_width || "800px"
194
- );
195
-
196
- return (
197
- <>
198
- <ViewportBadge />
199
-
200
- {/* ========== CONTENT ========== */}
201
- <SettingsSection title="Content" defaultOpen icon={<ContentIcon />}>
202
- <SettingsField label="Headline">
203
- <StyledInput
204
- value={block.headline || ""}
205
- onFocus={snapshotOnFocus}
206
- onChange={(v) => updateDebounced({ headline: v })}
207
- placeholder="Page Headline"
208
- />
209
- </SettingsField>
210
-
211
- <SettingsField label="Subheadline">
212
- <StyledInput
213
- value={block.subheadline || ""}
214
- onFocus={snapshotOnFocus}
215
- onChange={(v) => updateDebounced({ subheadline: v })}
216
- placeholder="Supporting text"
217
- />
218
- </SettingsField>
219
- </SettingsSection>
220
-
221
- {/* ========== CTA BUTTON ========== */}
222
- <SettingsSection title="CTA Button" icon={<CTAButtonIcon />}>
223
- <SettingsField label="Button Text">
224
- <StyledInput
225
- value={cta.text || ""}
226
- onFocus={snapshotOnFocus}
227
- onChange={(v) =>
228
- updateDebounced({ cta_button: { ...cta, text: v } })
229
- }
230
- placeholder="View Project"
231
- />
232
- </SettingsField>
233
-
234
- <SettingsField label="Button URL">
235
- <StyledInput
236
- value={cta.url || ""}
237
- onFocus={snapshotOnFocus}
238
- onChange={(v) =>
239
- updateDebounced({ cta_button: { ...cta, url: v } })
240
- }
241
- placeholder="/work/project-name"
242
- />
243
- </SettingsField>
244
-
245
- <SettingsField label="Style">
246
- <div className="flex gap-1">
247
- {(["primary", "secondary", "outline", "text"] as const).map((s) => (
248
- <button
249
- key={s}
250
- onClick={() =>
251
- update({ cta_button: { ...cta, style: s } })
252
- }
253
- className={`flex-1 rounded border py-1 text-[10px] capitalize transition-colors ${
254
- (cta.style || "primary") === s
255
- ? "border-[#076bff] bg-[#076bff]/20 text-neutral-900"
256
- : "border-neutral-200 bg-white text-neutral-500 hover:border-neutral-600"
257
- }`}
258
- >
259
- {s}
260
- </button>
261
- ))}
262
- </div>
263
- </SettingsField>
264
-
265
- <StyledCheckbox
266
- label="Open in new tab"
267
- checked={cta.target_blank || false}
268
- onChange={(v) =>
269
- update({ cta_button: { ...cta, target_blank: v } })
270
- }
271
- />
272
- </SettingsSection>
273
-
274
- {/* ========== MEDIA BACKGROUND ========== */}
275
- <SettingsSection title="Cover Background" defaultOpen icon={<CoverBackgroundIcon />}>
276
- <SettingsField label="Media Type">
277
- <div className="flex gap-1">
278
- {(["image", "video"] as const).map((t) => (
279
- <button
280
- key={t}
281
- onClick={() => update({ media_type: t })}
282
- className={`flex-1 rounded border py-1.5 text-xs capitalize transition-colors ${
283
- (block.media_type || "image") === t
284
- ? "border-[#076bff] bg-[#076bff]/20 text-neutral-900"
285
- : "border-neutral-200 bg-white text-neutral-500 hover:border-neutral-600"
286
- }`}
287
- >
288
- {t}
289
- </button>
290
- ))}
291
- </div>
292
- </SettingsField>
293
-
294
- <SettingsField
295
- label={block.media_type === "video" ? "Video Path" : "Image Path"}
296
- >
297
- <AssetPathInput
298
- value={block.media_path || ""}
299
- onFocus={snapshotOnFocus}
300
- onChange={(v) => updateDebounced({ media_path: v })}
301
- placeholder={
302
- block.media_type === "video"
303
- ? "projects/cover.mp4"
304
- : "projects/cover.jpg"
305
- }
306
- filterType={block.media_type === "video" ? "video" : "image"}
307
- />
308
- </SettingsField>
309
-
310
- {block.media_type === "video" && (
311
- <SettingsField label="Poster Image" hint="Fallback while video loads">
312
- <AssetPathInput
313
- value={block.video_poster || ""}
314
- onFocus={snapshotOnFocus}
315
- onChange={(v) => updateDebounced({ video_poster: v })}
316
- placeholder="projects/cover-poster.jpg"
317
- filterType="image"
318
- />
319
- </SettingsField>
320
- )}
321
-
322
- <SettingsField label="Size">
323
- <div className="flex gap-1">
324
- {(["cover", "contain", "none"] as const).map((s) => (
325
- <button
326
- key={s}
327
- onClick={() => update({ background_size: s })}
328
- className={`flex-1 rounded border py-1 text-[10px] transition-colors ${
329
- (block.background_size || "cover") === s
330
- ? "border-[#076bff] bg-[#076bff]/20 text-neutral-900"
331
- : "border-neutral-200 bg-white text-neutral-500 hover:border-neutral-600"
332
- }`}
333
- >
334
- {s === "none" ? "No Scale" : s.charAt(0).toUpperCase() + s.slice(1)}
335
- </button>
336
- ))}
337
- </div>
338
- </SettingsField>
339
-
340
- <SettingsField label="Position">
341
- <PositionGrid
342
- value={block.background_position || "center center"}
343
- onChange={(v) => update({ background_position: v })}
344
- />
345
- </SettingsField>
346
-
347
- <SettingsField label="Repeat">
348
- <select
349
- value={block.background_repeat || "no-repeat"}
350
- onChange={(e) =>
351
- update({
352
- background_repeat: e.target.value as CoverBlock["background_repeat"],
353
- })
354
- }
355
- className={SELECT_CLASS}
356
- >
357
- <option value="no-repeat">No Repeat</option>
358
- <option value="repeat">Repeat</option>
359
- <option value="repeat-x">Repeat X</option>
360
- <option value="repeat-y">Repeat Y</option>
361
- </select>
362
- </SettingsField>
363
- </SettingsSection>
364
-
365
- {/* ========== OVERLAY ========== */}
366
- <SettingsSection title="Cover Effects" icon={<CoverEffectsIcon />}>
367
- {/* Toggle: Custom gradient vs Preset overlay */}
368
- <SettingsField label="Overlay Mode">
369
- <div className="flex gap-1">
370
- {(["preset", "custom"] as const).map((mode) => {
371
- const isActive = mode === "custom" ? !!block.overlay_gradient : !block.overlay_gradient;
372
- return (
373
- <button
374
- key={mode}
375
- onClick={() => {
376
- if (mode === "custom" && !block.overlay_gradient) {
377
- // Switch to custom: initialize with a dark semi-transparent solid
378
- update({ overlay_gradient: "#00000080" });
379
- } else if (mode === "preset" && block.overlay_gradient) {
380
- // Switch to preset: clear overlay_gradient
381
- update({ overlay_gradient: undefined });
382
- }
383
- }}
384
- className={`flex-1 rounded border py-1 text-[10px] capitalize transition-colors ${
385
- isActive
386
- ? "border-[#076bff] bg-[#076bff]/20 text-neutral-900"
387
- : "border-neutral-200 bg-white text-neutral-500 hover:border-neutral-600"
388
- }`}
389
- >
390
- {mode === "preset" ? "Preset" : "Custom"}
391
- </button>
392
- );
393
- })}
394
- </div>
395
- </SettingsField>
396
-
397
- {/* Preset overlay controls */}
398
- {!block.overlay_gradient && (
399
- <>
400
- <SettingsField label="Overlay">
401
- <select
402
- value={block.overlay || "dark"}
403
- onChange={(e) =>
404
- update({
405
- overlay: e.target.value as CoverBlock["overlay"],
406
- })
407
- }
408
- className={SELECT_CLASS}
409
- >
410
- <option value="none">None</option>
411
- <option value="dark">Dark</option>
412
- <option value="light">Light</option>
413
- <option value="gradient-bottom">Gradient (Bottom)</option>
414
- <option value="gradient-top">Gradient (Top)</option>
415
- </select>
416
- </SettingsField>
417
-
418
- {block.overlay && block.overlay !== "none" && (
419
- <SettingsField label="Opacity">
420
- <div className="flex items-center gap-2">
421
- <input
422
- type="range"
423
- min={0}
424
- max={100}
425
- value={block.overlay_opacity ?? 50}
426
- onChange={(e) =>
427
- update({ overlay_opacity: parseInt(e.target.value, 10) })
428
- }
429
- className="flex-1 accent-[#076bff]"
430
- />
431
- <span className="text-xs text-neutral-500 w-8 text-right">
432
- {block.overlay_opacity ?? 50}%
433
- </span>
434
- </div>
435
- </SettingsField>
436
- )}
437
- </>
438
- )}
439
-
440
- {/* Custom overlay gradient (Phase 4) */}
441
- {block.overlay_gradient && (
442
- <SettingsField label="Overlay Color">
443
- <ColorSwatchPicker
444
- value={(() => {
445
- const parsed = parseColorField(block.overlay_gradient);
446
- return typeof parsed === "string" ? parsed : parsed;
447
- })()}
448
- onChange={(val: ColorField) => {
449
- update({ overlay_gradient: serializeColorField(val) });
450
- }}
451
- swatches={paletteSwatches}
452
- allowGradients
453
- />
454
- </SettingsField>
455
- )}
456
- </SettingsSection>
457
-
458
- {/* ========== LAYOUT ========== */}
459
- <SettingsSection title="Layout" icon={<LayoutIcon />}>
460
- <ResponsiveField
461
- label="Content Pos"
462
- block={block as ContentBlock}
463
- property="content_align_h"
464
- onReset={() => {
465
- resetOverride("content_align_h");
466
- resetOverride("content_align_v");
467
- }}
468
- >
469
- <ContentAlignGrid
470
- alignH={effectiveAlignH}
471
- alignV={effectiveAlignV}
472
- onChangeH={(v) => updateResponsive("content_align_h", v)}
473
- onChangeV={(v) => updateResponsive("content_align_v", v)}
474
- />
475
- </ResponsiveField>
476
-
477
- <ResponsiveField
478
- label="Max Width"
479
- block={block as ContentBlock}
480
- property="content_max_width"
481
- hint="Text container width"
482
- onReset={() => resetOverride("content_max_width")}
483
- >
484
- <StyledInput
485
- value={effectiveMaxWidth}
486
- onFocus={snapshotOnFocus}
487
- onChange={(v) => updateResponsiveDebounced("content_max_width", v)}
488
- placeholder="800px"
489
- />
490
- </ResponsiveField>
491
-
492
- <ResponsiveField
493
- label="Height"
494
- block={block as ContentBlock}
495
- property="height"
496
- onReset={() => resetOverride("height")}
497
- >
498
- <select
499
- value={effectiveHeight}
500
- onChange={(e) =>
501
- updateResponsive("height", e.target.value)
502
- }
503
- className={SELECT_CLASS}
504
- >
505
- <option value="100vh">Full Screen (100vh)</option>
506
- <option value="80vh">Large (80vh)</option>
507
- <option value="60vh">Medium (60vh)</option>
508
- <option value="40vh">Small (40vh)</option>
509
- <option value="custom">Custom</option>
510
- </select>
511
- </ResponsiveField>
512
-
513
- {effectiveHeight === "custom" && (
514
- <ResponsiveField
515
- label="Custom Height"
516
- block={block as ContentBlock}
517
- property="custom_height"
518
- onReset={() => resetOverride("custom_height")}
519
- >
520
- <StyledInput
521
- value={effectiveCustomHeight}
522
- onFocus={snapshotOnFocus}
523
- onChange={(v) => updateResponsiveDebounced("custom_height", v)}
524
- placeholder="500px, 70vh"
525
- />
526
- </ResponsiveField>
527
- )}
528
-
529
- {/* Legacy mobile_height removed — use tablet/phone viewport overrides instead */}
530
- </SettingsSection>
531
-
532
- {/* ========== APPEARANCE ========== */}
533
- <SettingsSection title="Appearance" icon={<AppearanceIcon />}>
534
- <SettingsField label="Text Color">
535
- <ColorSwatchPicker
536
- value={block.text_color || ""}
537
- onChange={(hex) => update({ text_color: (typeof hex === "string" ? hex : "#ffffff") || "#ffffff" })}
538
- swatches={paletteSwatches}
539
- />
540
- </SettingsField>
541
-
542
- <StyledCheckbox
543
- label="Show scroll down arrow"
544
- checked={block.show_scroll_indicator || false}
545
- onChange={(v) => update({ show_scroll_indicator: v })}
546
- />
547
- </SettingsSection>
548
- </>
549
- );
550
- }
@@ -1,146 +0,0 @@
1
- "use client";
2
-
3
- import { adminAssetUrl } from "../../../lib/assets";
4
- import { ThumbBadge } from "./shared";
5
- import type { CoverBlock } from "../../../lib/sanity/types";
6
-
7
- export default function LiveCoverPreview({ block }: { block: CoverBlock }) {
8
- const mediaSrc = block.media_path ? adminAssetUrl(block.media_path) : undefined;
9
- const posterSrc = block.video_poster ? adminAssetUrl(block.video_poster) : undefined;
10
- const isVideo = block.media_type === "video";
11
-
12
- const overlayOpacity = (block.overlay_opacity ?? 50) / 100;
13
- let overlayBg = "";
14
- switch (block.overlay) {
15
- case "dark":
16
- overlayBg = `rgba(0,0,0,${overlayOpacity})`;
17
- break;
18
- case "light":
19
- overlayBg = `rgba(255,255,255,${overlayOpacity})`;
20
- break;
21
- case "gradient-bottom":
22
- overlayBg = `linear-gradient(to top, rgba(0,0,0,${overlayOpacity}) 0%, transparent 60%)`;
23
- break;
24
- case "gradient-top":
25
- overlayBg = `linear-gradient(to bottom, rgba(0,0,0,${overlayOpacity}) 0%, transparent 60%)`;
26
- break;
27
- }
28
-
29
- const alignMap: Record<string, string> = {
30
- top: "flex-start",
31
- center: "center",
32
- bottom: "flex-end",
33
- left: "flex-start",
34
- right: "flex-end",
35
- };
36
-
37
- const textColor = block.text_color || "#ffffff";
38
-
39
- // Resolve height — must match CoverBlockRenderer on public site
40
- const height =
41
- block.height === "custom" && block.custom_height
42
- ? block.custom_height
43
- : block.height || "100vh";
44
-
45
- return (
46
- <div
47
- className="relative rounded overflow-hidden"
48
- style={{
49
- height,
50
- minHeight: height,
51
- display: "flex",
52
- alignItems: alignMap[block.content_align_v || "center"],
53
- justifyContent: alignMap[block.content_align_h || "center"],
54
- color: textColor,
55
- }}
56
- >
57
- {/* Background */}
58
- {mediaSrc && !isVideo && (
59
- <>
60
- {/* eslint-disable-next-line @next/next/no-img-element */}
61
- <img
62
- src={mediaSrc}
63
- alt=""
64
- className="absolute inset-0 w-full h-full"
65
- style={{
66
- objectFit: (block.background_size || "cover") as "cover" | "contain" | "none",
67
- objectPosition: block.background_position || "center center",
68
- }}
69
- />
70
- {block.media_path && <ThumbBadge assetPath={block.media_path} />}
71
- </>
72
- )}
73
- {mediaSrc && isVideo && (
74
- <video
75
- src={mediaSrc}
76
- poster={posterSrc}
77
- autoPlay
78
- loop
79
- muted
80
- playsInline
81
- className="absolute inset-0 w-full h-full"
82
- style={{
83
- objectFit: (block.background_size || "cover") as "cover" | "contain" | "none",
84
- objectPosition: block.background_position || "center center",
85
- }}
86
- />
87
- )}
88
- {!mediaSrc && posterSrc && (
89
- // eslint-disable-next-line @next/next/no-img-element
90
- <img
91
- src={posterSrc}
92
- alt=""
93
- className="absolute inset-0 w-full h-full object-cover"
94
- />
95
- )}
96
- {!mediaSrc && !posterSrc && <div className="absolute inset-0 bg-neutral-900" />}
97
-
98
- {/* Overlay */}
99
- {overlayBg && (
100
- <div
101
- className="absolute inset-0"
102
- style={{ background: overlayBg }}
103
- />
104
- )}
105
-
106
- {/* Content */}
107
- <div
108
- className="relative z-10 flex flex-col gap-3 py-12"
109
- style={{
110
- maxWidth: block.content_max_width || "800px",
111
- textAlign: (block.content_align_h || "center") as "left" | "center" | "right",
112
- width: "100%",
113
- paddingLeft: "var(--grid-padding, 24px)",
114
- paddingRight: "var(--grid-padding, 24px)",
115
- }}
116
- >
117
- {block.headline && (
118
- <h1 className="text-2xl font-bold uppercase tracking-widest">
119
- {block.headline}
120
- </h1>
121
- )}
122
- {block.subheadline && (
123
- <p className="text-xs opacity-80 uppercase tracking-wider">
124
- {block.subheadline}
125
- </p>
126
- )}
127
- {block.cta_button?.text && (
128
- <div>
129
- <span className="inline-block rounded-lg bg-white text-black text-xs px-6 py-3">
130
- {block.cta_button.text}
131
- </span>
132
- </div>
133
- )}
134
- </div>
135
-
136
- {/* Scroll indicator */}
137
- {block.show_scroll_indicator && (
138
- <div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 opacity-60">
139
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
140
- <path d="M12 5v14M5 12l7 7 7-7" />
141
- </svg>
142
- </div>
143
- )}
144
- </div>
145
- );
146
- }