@morphika/andami 0.2.12 → 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.
- package/README.md +2 -1
- package/app/admin/pages/[slug]/page.tsx +39 -2
- package/components/blocks/BlockRenderer.tsx +0 -7
- package/components/blocks/CoverSectionRenderer.tsx +295 -0
- package/components/blocks/PageRenderer.tsx +13 -9
- package/components/builder/BlockLivePreview.tsx +0 -5
- package/components/builder/BlockTypePicker.tsx +0 -1
- package/components/builder/ColorSwatchPicker.tsx +2 -2
- package/components/builder/CoverRowResizeHandle.tsx +180 -0
- package/components/builder/CoverSectionCanvas.tsx +260 -0
- package/components/builder/ReadOnlyFrame.tsx +127 -3
- package/components/builder/SectionTypePicker.tsx +29 -0
- package/components/builder/SectionV2Canvas.tsx +4 -1
- package/components/builder/SectionV2Column.tsx +15 -20
- package/components/builder/SettingsPanel.tsx +14 -0
- package/components/builder/SortableRow.tsx +7 -21
- package/components/builder/blockStyles.tsx +13 -14
- package/components/builder/editors/index.ts +0 -1
- package/components/builder/index.ts +1 -0
- package/components/builder/live-preview/RichTextEditor.tsx +23 -2
- package/components/builder/live-preview/index.ts +0 -1
- package/components/builder/settings-panel/BlockSettings.tsx +0 -7
- package/components/builder/settings-panel/CoverSectionSettings.tsx +296 -0
- package/components/builder/settings-panel/index.ts +1 -0
- package/components/builder/settings-panel/useSettingsPanelSelection.ts +36 -2
- package/lib/animation/enter-types.ts +0 -1
- package/lib/animation/hover-effect-types.ts +0 -1
- package/lib/builder/defaults.ts +43 -22
- package/lib/builder/serializer/normalizers.ts +34 -1
- package/lib/builder/serializer/serializers.ts +39 -2
- package/lib/builder/store-blocks.ts +11 -3
- package/lib/builder/store-cover.ts +220 -0
- package/lib/builder/store-helpers.ts +81 -4
- package/lib/builder/store-sections.ts +12 -2
- package/lib/builder/store.ts +11 -2
- package/lib/builder/types.ts +15 -2
- package/lib/sanity/types.ts +79 -43
- package/lib/version.ts +1 -1
- package/package.json +1 -1
- package/sanity/schemas/blocks/index.ts +1 -2
- package/sanity/schemas/index.ts +5 -3
- package/sanity/schemas/objects/coverSection.ts +317 -0
- package/sanity/schemas/objects/parallaxSlide.ts +0 -1
- package/sanity/schemas/page.ts +1 -1
- package/sanity/schemas/pageSectionV2.ts +0 -1
- package/components/blocks/CoverBlockRenderer.tsx +0 -261
- package/components/builder/editors/CoverBlockEditor.tsx +0 -550
- package/components/builder/live-preview/LiveCoverPreview.tsx +0 -146
- package/sanity/schemas/blocks/coverBlock.ts +0 -229
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { defineField, defineType } from "sanity";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* coverSection — Full-viewport section with proportional rows and background media.
|
|
5
|
+
*
|
|
6
|
+
* A new ContentItem type alongside pageSectionV2, parallaxGroup, and customSectionInstance.
|
|
7
|
+
* Uses the same column/block system as V2 but with fixed-height proportional rows
|
|
8
|
+
* instead of content-driven rows. Overflow is always hidden.
|
|
9
|
+
*
|
|
10
|
+
* Session 176: Cover Sections — Phase 1 (Schema).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const columnFields = [
|
|
14
|
+
defineField({
|
|
15
|
+
name: "grid_column",
|
|
16
|
+
title: "Grid Column",
|
|
17
|
+
type: "number",
|
|
18
|
+
description: "1-based start position (1–12)",
|
|
19
|
+
validation: (Rule) => Rule.required().min(1).max(12),
|
|
20
|
+
}),
|
|
21
|
+
defineField({
|
|
22
|
+
name: "grid_row",
|
|
23
|
+
title: "Grid Row",
|
|
24
|
+
type: "number",
|
|
25
|
+
description: "1-based row index (maps to cover_rows)",
|
|
26
|
+
validation: (Rule) => Rule.required().min(1),
|
|
27
|
+
}),
|
|
28
|
+
defineField({
|
|
29
|
+
name: "span",
|
|
30
|
+
title: "Column Span",
|
|
31
|
+
type: "number",
|
|
32
|
+
description: "How many grid columns (1–12)",
|
|
33
|
+
validation: (Rule) => Rule.required().min(1).max(12),
|
|
34
|
+
}),
|
|
35
|
+
defineField({
|
|
36
|
+
name: "blocks",
|
|
37
|
+
title: "Blocks",
|
|
38
|
+
type: "array",
|
|
39
|
+
of: [
|
|
40
|
+
{ type: "textBlock" },
|
|
41
|
+
{ type: "imageBlock" },
|
|
42
|
+
{ type: "imageGridBlock" },
|
|
43
|
+
{ type: "videoBlock" },
|
|
44
|
+
{ type: "spacerBlock" },
|
|
45
|
+
{ type: "buttonBlock" },
|
|
46
|
+
],
|
|
47
|
+
}),
|
|
48
|
+
defineField({
|
|
49
|
+
name: "enter_animation",
|
|
50
|
+
title: "Enter Animation",
|
|
51
|
+
type: "enterAnimationConfig",
|
|
52
|
+
}),
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const responsiveColumnOverrideFields = [
|
|
56
|
+
defineField({
|
|
57
|
+
name: "columns",
|
|
58
|
+
title: "Column Overrides",
|
|
59
|
+
type: "array",
|
|
60
|
+
of: [
|
|
61
|
+
{
|
|
62
|
+
type: "object",
|
|
63
|
+
name: "columnOverride",
|
|
64
|
+
fields: [
|
|
65
|
+
defineField({ name: "grid_column", title: "Grid Column", type: "number" }),
|
|
66
|
+
defineField({ name: "grid_row", title: "Grid Row", type: "number" }),
|
|
67
|
+
defineField({ name: "span", title: "Span", type: "number" }),
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
}),
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
const responsiveRowOverrideFields = [
|
|
75
|
+
defineField({
|
|
76
|
+
name: "cover_rows",
|
|
77
|
+
title: "Row Height Overrides",
|
|
78
|
+
type: "array",
|
|
79
|
+
of: [
|
|
80
|
+
{
|
|
81
|
+
type: "object",
|
|
82
|
+
name: "coverRowOverride",
|
|
83
|
+
fields: [
|
|
84
|
+
defineField({ name: "height_percent", title: "Height %", type: "number" }),
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
}),
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
const responsiveSettingsFields = [
|
|
92
|
+
defineField({ name: "col_gap", title: "Column Gap", type: "number" }),
|
|
93
|
+
defineField({ name: "row_gap", title: "Row Gap", type: "number" }),
|
|
94
|
+
defineField({ name: "spacing_top", type: "string", title: "Spacing Top" }),
|
|
95
|
+
defineField({ name: "spacing_right", type: "string", title: "Spacing Right" }),
|
|
96
|
+
defineField({ name: "spacing_bottom", type: "string", title: "Spacing Bottom" }),
|
|
97
|
+
defineField({ name: "spacing_left", type: "string", title: "Spacing Left" }),
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
export default defineType({
|
|
101
|
+
name: "coverSection",
|
|
102
|
+
title: "Cover Section",
|
|
103
|
+
type: "object",
|
|
104
|
+
fields: [
|
|
105
|
+
// ── Background ──
|
|
106
|
+
defineField({
|
|
107
|
+
name: "background_type",
|
|
108
|
+
title: "Background Type",
|
|
109
|
+
type: "string",
|
|
110
|
+
options: {
|
|
111
|
+
list: [
|
|
112
|
+
{ title: "Image", value: "image" },
|
|
113
|
+
{ title: "Video", value: "video" },
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
initialValue: "image",
|
|
117
|
+
}),
|
|
118
|
+
defineField({
|
|
119
|
+
name: "background_image",
|
|
120
|
+
title: "Background Image",
|
|
121
|
+
type: "string",
|
|
122
|
+
description: "Asset path for background image",
|
|
123
|
+
}),
|
|
124
|
+
defineField({
|
|
125
|
+
name: "background_video",
|
|
126
|
+
title: "Background Video",
|
|
127
|
+
type: "string",
|
|
128
|
+
description: "Asset path for background video",
|
|
129
|
+
}),
|
|
130
|
+
defineField({
|
|
131
|
+
name: "background_position",
|
|
132
|
+
title: "Background Position",
|
|
133
|
+
type: "string",
|
|
134
|
+
description: "CSS background-position value",
|
|
135
|
+
initialValue: "center center",
|
|
136
|
+
}),
|
|
137
|
+
defineField({
|
|
138
|
+
name: "background_size",
|
|
139
|
+
title: "Background Size",
|
|
140
|
+
type: "string",
|
|
141
|
+
options: {
|
|
142
|
+
list: [
|
|
143
|
+
{ title: "Cover", value: "cover" },
|
|
144
|
+
{ title: "Contain", value: "contain" },
|
|
145
|
+
{ title: "Auto", value: "auto" },
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
initialValue: "cover",
|
|
149
|
+
}),
|
|
150
|
+
defineField({
|
|
151
|
+
name: "background_overlay_color",
|
|
152
|
+
title: "Overlay Color",
|
|
153
|
+
type: "string",
|
|
154
|
+
description: "Hex color for background overlay",
|
|
155
|
+
initialValue: "#000000",
|
|
156
|
+
}),
|
|
157
|
+
defineField({
|
|
158
|
+
name: "background_overlay_opacity",
|
|
159
|
+
title: "Overlay Opacity",
|
|
160
|
+
type: "number",
|
|
161
|
+
description: "0–100",
|
|
162
|
+
initialValue: 0,
|
|
163
|
+
validation: (Rule) => Rule.min(0).max(100),
|
|
164
|
+
}),
|
|
165
|
+
|
|
166
|
+
// ── Section Height ──
|
|
167
|
+
defineField({
|
|
168
|
+
name: "height",
|
|
169
|
+
title: "Section Height",
|
|
170
|
+
type: "string",
|
|
171
|
+
options: {
|
|
172
|
+
list: [
|
|
173
|
+
{ title: "Full Viewport (100vh)", value: "100vh" },
|
|
174
|
+
{ title: "80% Viewport (80vh)", value: "80vh" },
|
|
175
|
+
{ title: "50% Viewport (50vh)", value: "50vh" },
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
initialValue: "100vh",
|
|
179
|
+
}),
|
|
180
|
+
|
|
181
|
+
// ── Cover Rows (explicit, proportional) ──
|
|
182
|
+
defineField({
|
|
183
|
+
name: "cover_rows",
|
|
184
|
+
title: "Cover Rows",
|
|
185
|
+
type: "array",
|
|
186
|
+
description: "Proportional rows — heights must sum to 100%",
|
|
187
|
+
of: [
|
|
188
|
+
{
|
|
189
|
+
type: "object",
|
|
190
|
+
name: "coverRow",
|
|
191
|
+
fields: [
|
|
192
|
+
defineField({
|
|
193
|
+
name: "height_percent",
|
|
194
|
+
title: "Height (%)",
|
|
195
|
+
type: "number",
|
|
196
|
+
description: "Row height as percentage of section (5–95)",
|
|
197
|
+
validation: (Rule) => Rule.required().min(5).max(95),
|
|
198
|
+
}),
|
|
199
|
+
defineField({
|
|
200
|
+
name: "vertical_align",
|
|
201
|
+
title: "Vertical Alignment",
|
|
202
|
+
type: "string",
|
|
203
|
+
description: "How content is aligned vertically within this row",
|
|
204
|
+
options: {
|
|
205
|
+
list: [
|
|
206
|
+
{ title: "Top", value: "start" },
|
|
207
|
+
{ title: "Center", value: "center" },
|
|
208
|
+
{ title: "Bottom", value: "end" },
|
|
209
|
+
],
|
|
210
|
+
},
|
|
211
|
+
initialValue: "start",
|
|
212
|
+
}),
|
|
213
|
+
],
|
|
214
|
+
preview: {
|
|
215
|
+
select: { height: "height_percent", align: "vertical_align" },
|
|
216
|
+
prepare({ height, align }) {
|
|
217
|
+
return { title: `${height ?? 0}%`, subtitle: align ?? "start" };
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
validation: (Rule) => Rule.required().min(1).max(5),
|
|
223
|
+
}),
|
|
224
|
+
|
|
225
|
+
// ── Columns (same structure as V2) ──
|
|
226
|
+
defineField({
|
|
227
|
+
name: "columns",
|
|
228
|
+
title: "Columns",
|
|
229
|
+
type: "array",
|
|
230
|
+
of: [
|
|
231
|
+
{
|
|
232
|
+
type: "object",
|
|
233
|
+
name: "sectionColumn",
|
|
234
|
+
fields: columnFields,
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
}),
|
|
238
|
+
|
|
239
|
+
// ── Section Settings (subset of V2 — no background/border, those are section-level) ──
|
|
240
|
+
defineField({
|
|
241
|
+
name: "settings",
|
|
242
|
+
title: "Section Settings",
|
|
243
|
+
type: "object",
|
|
244
|
+
fields: [
|
|
245
|
+
defineField({ name: "grid_columns", title: "Grid Columns", type: "number" }),
|
|
246
|
+
defineField({ name: "col_gap", title: "Column Gap", type: "number" }),
|
|
247
|
+
defineField({ name: "row_gap", title: "Row Gap", type: "number" }),
|
|
248
|
+
// Spacing (padding TRBL)
|
|
249
|
+
defineField({ name: "spacing_top", title: "Spacing Top", type: "string" }),
|
|
250
|
+
defineField({ name: "spacing_right", title: "Spacing Right", type: "string" }),
|
|
251
|
+
defineField({ name: "spacing_bottom", title: "Spacing Bottom", type: "string" }),
|
|
252
|
+
defineField({ name: "spacing_left", title: "Spacing Left", type: "string" }),
|
|
253
|
+
// Animation
|
|
254
|
+
defineField({
|
|
255
|
+
name: "enter_animation",
|
|
256
|
+
title: "Enter Animation",
|
|
257
|
+
type: "enterAnimationConfig",
|
|
258
|
+
}),
|
|
259
|
+
defineField({
|
|
260
|
+
name: "stagger",
|
|
261
|
+
title: "Stagger",
|
|
262
|
+
type: "object",
|
|
263
|
+
fields: [
|
|
264
|
+
defineField({ name: "enabled", title: "Enabled", type: "boolean" }),
|
|
265
|
+
defineField({ name: "delayPerChild", title: "Delay Per Child", type: "number" }),
|
|
266
|
+
defineField({
|
|
267
|
+
name: "direction",
|
|
268
|
+
title: "Direction",
|
|
269
|
+
type: "string",
|
|
270
|
+
options: { list: ["left-to-right", "right-to-left"] },
|
|
271
|
+
}),
|
|
272
|
+
],
|
|
273
|
+
}),
|
|
274
|
+
],
|
|
275
|
+
}),
|
|
276
|
+
|
|
277
|
+
// ── Responsive Overrides ──
|
|
278
|
+
defineField({
|
|
279
|
+
name: "responsive",
|
|
280
|
+
title: "Responsive Overrides",
|
|
281
|
+
type: "object",
|
|
282
|
+
hidden: true,
|
|
283
|
+
fields: [
|
|
284
|
+
defineField({
|
|
285
|
+
name: "tablet",
|
|
286
|
+
title: "Tablet",
|
|
287
|
+
type: "object",
|
|
288
|
+
fields: [
|
|
289
|
+
...responsiveColumnOverrideFields,
|
|
290
|
+
...responsiveRowOverrideFields,
|
|
291
|
+
...responsiveSettingsFields,
|
|
292
|
+
],
|
|
293
|
+
}),
|
|
294
|
+
defineField({
|
|
295
|
+
name: "phone",
|
|
296
|
+
title: "Phone",
|
|
297
|
+
type: "object",
|
|
298
|
+
fields: [
|
|
299
|
+
...responsiveColumnOverrideFields,
|
|
300
|
+
...responsiveRowOverrideFields,
|
|
301
|
+
...responsiveSettingsFields,
|
|
302
|
+
],
|
|
303
|
+
}),
|
|
304
|
+
],
|
|
305
|
+
}),
|
|
306
|
+
],
|
|
307
|
+
preview: {
|
|
308
|
+
select: { height: "height", rows: "cover_rows" },
|
|
309
|
+
prepare({ height, rows }) {
|
|
310
|
+
const rowCount = Array.isArray(rows) ? rows.length : 0;
|
|
311
|
+
return {
|
|
312
|
+
title: "Cover Section",
|
|
313
|
+
subtitle: `${height || "100vh"} · ${rowCount} row${rowCount !== 1 ? "s" : ""}`,
|
|
314
|
+
};
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
});
|
package/sanity/schemas/page.ts
CHANGED
|
@@ -42,7 +42,7 @@ export default defineType({
|
|
|
42
42
|
name: "content_rows",
|
|
43
43
|
title: "Content Rows",
|
|
44
44
|
type: "array",
|
|
45
|
-
of: [{ type: "pageSectionV2" }, { type: "parallaxGroup" }, { type: "customSectionInstance" }],
|
|
45
|
+
of: [{ type: "pageSectionV2" }, { type: "parallaxGroup" }, { type: "customSectionInstance" }, { type: "coverSection" }],
|
|
46
46
|
}),
|
|
47
47
|
defineField({
|
|
48
48
|
name: "metadata",
|
|
@@ -1,261 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import type { CoverBlock } from "../../lib/sanity/types";
|
|
4
|
-
import { useAssetUrl } from "../../lib/contexts/AssetContext";
|
|
5
|
-
import { handleImageRetry, handleVideoRetry } from "../../lib/asset-retry";
|
|
6
|
-
import { parseColorField, colorToCSS, isGradient } from "../../lib/color-utils";
|
|
7
|
-
|
|
8
|
-
function getOverlayStyle(
|
|
9
|
-
overlay: CoverBlock["overlay"],
|
|
10
|
-
opacity: number
|
|
11
|
-
): React.CSSProperties | null {
|
|
12
|
-
const alpha = opacity / 100;
|
|
13
|
-
switch (overlay) {
|
|
14
|
-
case "dark":
|
|
15
|
-
return { backgroundColor: `rgba(0,0,0,${alpha})` };
|
|
16
|
-
case "light":
|
|
17
|
-
return { backgroundColor: `rgba(255,255,255,${alpha})` };
|
|
18
|
-
case "gradient-bottom":
|
|
19
|
-
return {
|
|
20
|
-
background: `linear-gradient(to top, rgba(0,0,0,${alpha}) 0%, transparent 60%)`,
|
|
21
|
-
};
|
|
22
|
-
case "gradient-top":
|
|
23
|
-
return {
|
|
24
|
-
background: `linear-gradient(to bottom, rgba(0,0,0,${alpha}) 0%, transparent 60%)`,
|
|
25
|
-
};
|
|
26
|
-
default:
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function getAlignItems(v: CoverBlock["content_align_v"]): string {
|
|
32
|
-
switch (v) {
|
|
33
|
-
case "top":
|
|
34
|
-
return "flex-start";
|
|
35
|
-
case "bottom":
|
|
36
|
-
return "flex-end";
|
|
37
|
-
default:
|
|
38
|
-
return "center";
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function getJustify(h: CoverBlock["content_align_h"]): string {
|
|
43
|
-
switch (h) {
|
|
44
|
-
case "left":
|
|
45
|
-
return "flex-start";
|
|
46
|
-
case "right":
|
|
47
|
-
return "flex-end";
|
|
48
|
-
default:
|
|
49
|
-
return "center";
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function getTextAlign(
|
|
54
|
-
h: CoverBlock["content_align_h"]
|
|
55
|
-
): "left" | "center" | "right" {
|
|
56
|
-
return h || "center";
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export default function CoverBlockRenderer({
|
|
60
|
-
block,
|
|
61
|
-
}: {
|
|
62
|
-
block: CoverBlock;
|
|
63
|
-
}) {
|
|
64
|
-
const height =
|
|
65
|
-
block.height === "custom" && block.custom_height
|
|
66
|
-
? block.custom_height
|
|
67
|
-
: block.height || "100vh";
|
|
68
|
-
|
|
69
|
-
const mobileHeight =
|
|
70
|
-
block.mobile_height && block.mobile_height !== "same"
|
|
71
|
-
? block.mobile_height
|
|
72
|
-
: null;
|
|
73
|
-
|
|
74
|
-
// Phase 4: custom overlay_gradient takes precedence over hardcoded presets
|
|
75
|
-
const overlayStyle: React.CSSProperties | null = (() => {
|
|
76
|
-
if (block.overlay_gradient) {
|
|
77
|
-
const parsed = parseColorField(block.overlay_gradient);
|
|
78
|
-
if (isGradient(parsed)) {
|
|
79
|
-
return { backgroundImage: colorToCSS(parsed) };
|
|
80
|
-
}
|
|
81
|
-
// Solid color overlay from gradient field
|
|
82
|
-
return { backgroundColor: colorToCSS(parsed) };
|
|
83
|
-
}
|
|
84
|
-
return getOverlayStyle(block.overlay ?? "none", block.overlay_opacity ?? 50);
|
|
85
|
-
})();
|
|
86
|
-
|
|
87
|
-
const resolveAsset = useAssetUrl();
|
|
88
|
-
const mediaSrc = block.media_path ? resolveAsset(block.media_path) : undefined;
|
|
89
|
-
const posterSrc = block.video_poster
|
|
90
|
-
? resolveAsset(block.video_poster)
|
|
91
|
-
: undefined;
|
|
92
|
-
const isVideo = block.media_type === "video";
|
|
93
|
-
|
|
94
|
-
// BLK-010: Validate objectFit against allowed CSS values
|
|
95
|
-
const allowedObjectFit = new Set(["cover", "contain", "none", "fill", "scale-down"]);
|
|
96
|
-
const objectFit = allowedObjectFit.has(block.background_size || "") ? block.background_size! : "cover";
|
|
97
|
-
const objectPosition = block.background_position || "center center";
|
|
98
|
-
|
|
99
|
-
// Guard: text color must be a solid hex string. Fallback if gradient slips through.
|
|
100
|
-
const rawTextColor = block.text_color || "#ffffff";
|
|
101
|
-
const textColor = typeof rawTextColor === "string" ? rawTextColor : "#ffffff";
|
|
102
|
-
|
|
103
|
-
const ctaStyleClasses: Record<string, string> = {
|
|
104
|
-
primary:
|
|
105
|
-
"bg-brand-accent-alt text-brand-dark hover:bg-brand-accent",
|
|
106
|
-
secondary:
|
|
107
|
-
"bg-brand-dark text-white hover:bg-neutral-800",
|
|
108
|
-
outline:
|
|
109
|
-
"border-2 border-current bg-transparent hover:bg-white/10",
|
|
110
|
-
text: "underline underline-offset-4 hover:opacity-70",
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
// BLK-002: Sanitize _key and mobileHeight before CSS interpolation
|
|
114
|
-
// _key: allow only alphanumeric, hyphens, underscores (strip anything else)
|
|
115
|
-
const safeKey = block._key?.replace(/[^a-zA-Z0-9_-]/g, "") || "";
|
|
116
|
-
// mobileHeight: must match valid CSS height (number+unit or CSS keyword)
|
|
117
|
-
const safeMobileHeight =
|
|
118
|
-
mobileHeight && /^(\d+(\.\d+)?(px|vh|vw|em|rem|%|svh|dvh)|auto|inherit)$/i.test(mobileHeight)
|
|
119
|
-
? mobileHeight
|
|
120
|
-
: null;
|
|
121
|
-
|
|
122
|
-
return (
|
|
123
|
-
<>
|
|
124
|
-
{/* Mobile height override via inline style tag */}
|
|
125
|
-
{safeMobileHeight && safeKey && (
|
|
126
|
-
<style
|
|
127
|
-
dangerouslySetInnerHTML={{
|
|
128
|
-
__html: `@media(max-width:767px){.cover-block-${safeKey}{height:${safeMobileHeight}!important;min-height:${safeMobileHeight}!important;}}`,
|
|
129
|
-
}}
|
|
130
|
-
/>
|
|
131
|
-
)}
|
|
132
|
-
|
|
133
|
-
<section
|
|
134
|
-
className={`cover-block-${safeKey} relative flex overflow-hidden`}
|
|
135
|
-
style={{
|
|
136
|
-
height,
|
|
137
|
-
minHeight: height,
|
|
138
|
-
alignItems: getAlignItems(block.content_align_v),
|
|
139
|
-
justifyContent: getJustify(block.content_align_h),
|
|
140
|
-
color: textColor,
|
|
141
|
-
}}
|
|
142
|
-
>
|
|
143
|
-
{/* Media layer — uses <img> instead of background-image for error recovery */}
|
|
144
|
-
{mediaSrc && !isVideo && (
|
|
145
|
-
/* eslint-disable-next-line @next/next/no-img-element */
|
|
146
|
-
<img
|
|
147
|
-
src={mediaSrc}
|
|
148
|
-
alt={block.headline || ""}
|
|
149
|
-
onError={handleImageRetry}
|
|
150
|
-
className="absolute inset-0 h-full w-full"
|
|
151
|
-
style={{
|
|
152
|
-
objectFit: objectFit as React.CSSProperties["objectFit"],
|
|
153
|
-
objectPosition,
|
|
154
|
-
}}
|
|
155
|
-
/>
|
|
156
|
-
)}
|
|
157
|
-
|
|
158
|
-
{mediaSrc && isVideo && (
|
|
159
|
-
<video
|
|
160
|
-
src={mediaSrc}
|
|
161
|
-
poster={posterSrc}
|
|
162
|
-
autoPlay
|
|
163
|
-
loop
|
|
164
|
-
muted
|
|
165
|
-
playsInline
|
|
166
|
-
onError={handleVideoRetry}
|
|
167
|
-
className="absolute inset-0 h-full w-full"
|
|
168
|
-
style={{
|
|
169
|
-
objectFit: objectFit as React.CSSProperties["objectFit"],
|
|
170
|
-
objectPosition,
|
|
171
|
-
}}
|
|
172
|
-
/>
|
|
173
|
-
)}
|
|
174
|
-
|
|
175
|
-
{/* Fallback: poster only if no media_path but video_poster exists */}
|
|
176
|
-
{!mediaSrc && posterSrc && (
|
|
177
|
-
/* eslint-disable-next-line @next/next/no-img-element */
|
|
178
|
-
<img
|
|
179
|
-
src={posterSrc}
|
|
180
|
-
alt=""
|
|
181
|
-
onError={handleImageRetry}
|
|
182
|
-
className="absolute inset-0 h-full w-full"
|
|
183
|
-
style={{ objectFit: "cover", objectPosition: "center" }}
|
|
184
|
-
/>
|
|
185
|
-
)}
|
|
186
|
-
|
|
187
|
-
{/* No media fallback */}
|
|
188
|
-
{!mediaSrc && !posterSrc && (
|
|
189
|
-
<div className="absolute inset-0 bg-brand-dark" />
|
|
190
|
-
)}
|
|
191
|
-
|
|
192
|
-
{/* Overlay */}
|
|
193
|
-
{overlayStyle && <div className="absolute inset-0" style={overlayStyle} />}
|
|
194
|
-
|
|
195
|
-
{/* Content */}
|
|
196
|
-
<div
|
|
197
|
-
className="relative z-10 flex flex-col gap-4 py-12"
|
|
198
|
-
style={{
|
|
199
|
-
maxWidth: block.content_max_width || "800px",
|
|
200
|
-
textAlign: getTextAlign(block.content_align_h),
|
|
201
|
-
width: "100%",
|
|
202
|
-
paddingLeft: "var(--grid-padding, 24px)",
|
|
203
|
-
paddingRight: "var(--grid-padding, 24px)",
|
|
204
|
-
}}
|
|
205
|
-
>
|
|
206
|
-
{block.headline && (
|
|
207
|
-
<h1 className="font-sans text-4xl uppercase tracking-widest md:text-6xl lg:text-7xl">
|
|
208
|
-
{block.headline}
|
|
209
|
-
</h1>
|
|
210
|
-
)}
|
|
211
|
-
|
|
212
|
-
{block.subheadline && (
|
|
213
|
-
<p className="font-sans text-sm uppercase tracking-wider opacity-80 md:text-base">
|
|
214
|
-
{block.subheadline}
|
|
215
|
-
</p>
|
|
216
|
-
)}
|
|
217
|
-
|
|
218
|
-
{block.cta_button?.text && block.cta_button.url && (
|
|
219
|
-
<div>
|
|
220
|
-
<a
|
|
221
|
-
href={block.cta_button.url}
|
|
222
|
-
target={
|
|
223
|
-
block.cta_button.target_blank ? "_blank" : undefined
|
|
224
|
-
}
|
|
225
|
-
rel={
|
|
226
|
-
block.cta_button.target_blank
|
|
227
|
-
? "noopener noreferrer"
|
|
228
|
-
: undefined
|
|
229
|
-
}
|
|
230
|
-
className={`inline-block px-6 py-3 font-sans text-sm uppercase tracking-wider transition ${
|
|
231
|
-
ctaStyleClasses[block.cta_button.style || "primary"]
|
|
232
|
-
}`}
|
|
233
|
-
>
|
|
234
|
-
{block.cta_button.text}
|
|
235
|
-
</a>
|
|
236
|
-
</div>
|
|
237
|
-
)}
|
|
238
|
-
</div>
|
|
239
|
-
|
|
240
|
-
{/* Scroll indicator */}
|
|
241
|
-
{block.show_scroll_indicator && (
|
|
242
|
-
<div className="absolute bottom-6 left-1/2 z-10 -translate-x-1/2 animate-bounce">
|
|
243
|
-
<svg
|
|
244
|
-
width="24"
|
|
245
|
-
height="24"
|
|
246
|
-
viewBox="0 0 24 24"
|
|
247
|
-
fill="none"
|
|
248
|
-
stroke="currentColor"
|
|
249
|
-
strokeWidth="2"
|
|
250
|
-
strokeLinecap="round"
|
|
251
|
-
strokeLinejoin="round"
|
|
252
|
-
aria-hidden="true"
|
|
253
|
-
>
|
|
254
|
-
<path d="M12 5v14M5 12l7 7 7-7" />
|
|
255
|
-
</svg>
|
|
256
|
-
</div>
|
|
257
|
-
)}
|
|
258
|
-
</section>
|
|
259
|
-
</>
|
|
260
|
-
);
|
|
261
|
-
}
|