@bygghammar/cms-platform 0.1.0
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 +36 -0
- package/package.json +39 -0
- package/src/backoffice/inspectors/blocks.tsx +2318 -0
- package/src/backoffice/inspectors/fields.tsx +436 -0
- package/src/blocks/block-props-context.tsx +36 -0
- package/src/blocks/components/CabinTabsBlock.tsx +167 -0
- package/src/blocks/components/ChecklistBlock.tsx +68 -0
- package/src/blocks/components/CtaBannerBlock.tsx +65 -0
- package/src/blocks/components/FaqBlock.tsx +62 -0
- package/src/blocks/components/HeroFullBlock.tsx +201 -0
- package/src/blocks/components/IconGridBlock.tsx +125 -0
- package/src/blocks/components/ImageMarqueeBlock.tsx +209 -0
- package/src/blocks/components/InstagramBlock.tsx +160 -0
- package/src/blocks/components/LinkTilesBlock.tsx +182 -0
- package/src/blocks/components/MediaSplitBlock.tsx +427 -0
- package/src/blocks/components/PriceTableBlock.tsx +166 -0
- package/src/blocks/components/ProseBlock.tsx +77 -0
- package/src/blocks/components/RichTextBlock.tsx +79 -0
- package/src/blocks/icons.ts +63 -0
- package/src/blocks/index.ts +13 -0
- package/src/blocks/registry.tsx +322 -0
- package/src/blocks/types.ts +470 -0
- package/src/host/index.tsx +134 -0
- package/src/html/sanitize-html.ts +175 -0
- package/src/i18n/context.tsx +44 -0
- package/src/i18n/index.ts +6 -0
- package/src/i18n/locales.ts +24 -0
- package/src/i18n/resolve.ts +82 -0
- package/src/i18n/translations-registry.ts +26 -0
- package/src/i18n/types.ts +22 -0
- package/src/i18n/video.ts +36 -0
- package/src/index.ts +5 -0
- package/src/theme.css +53 -0
- package/src/ui/Lightbox.tsx +69 -0
- package/src/ui/Reveal.tsx +47 -0
- package/src/util/scroll-lock.ts +32 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { useT } from "../../i18n";
|
|
2
|
+
import { Reveal } from "../../ui/Reveal";
|
|
3
|
+
import { FocalImage } from "../../host";
|
|
4
|
+
import type { ImageMarqueeProps, ImageMarqueeItem } from "../types";
|
|
5
|
+
|
|
6
|
+
const TONE: Record<string, string> = {
|
|
7
|
+
white: "bg-white",
|
|
8
|
+
paper: "bg-paper",
|
|
9
|
+
ice: "bg-ice",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const GAP: Record<string, string> = {
|
|
13
|
+
sm: "gap-2",
|
|
14
|
+
md: "gap-4",
|
|
15
|
+
lg: "gap-6",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const ASPECT: Record<string, string> = {
|
|
19
|
+
portrait: "aspect-[3/4]",
|
|
20
|
+
square: "aspect-square",
|
|
21
|
+
landscape: "aspect-[4/3]",
|
|
22
|
+
mixed: "",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const SPEED_SECONDS: Record<string, number> = {
|
|
26
|
+
"ultra-slow": 160,
|
|
27
|
+
"very-slow": 110,
|
|
28
|
+
slow: 70,
|
|
29
|
+
normal: 45,
|
|
30
|
+
fast: 28,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const MIXED_CYCLE = ["aspect-[3/4]", "aspect-square", "aspect-[4/3]", "aspect-[3/4]"];
|
|
34
|
+
|
|
35
|
+
function Tile({
|
|
36
|
+
item,
|
|
37
|
+
aspectClass,
|
|
38
|
+
rounded,
|
|
39
|
+
}: {
|
|
40
|
+
item: ImageMarqueeItem;
|
|
41
|
+
aspectClass: string;
|
|
42
|
+
rounded: boolean;
|
|
43
|
+
}) {
|
|
44
|
+
const { t } = useT();
|
|
45
|
+
const alt = item.alt ? t(item.alt as Parameters<typeof t>[0]) : "";
|
|
46
|
+
return (
|
|
47
|
+
<div
|
|
48
|
+
className={`relative w-full overflow-hidden bg-ink/5 ${aspectClass} ${
|
|
49
|
+
rounded ? "rounded-2xl" : ""
|
|
50
|
+
}`}
|
|
51
|
+
>
|
|
52
|
+
{item.image ? (
|
|
53
|
+
<FocalImage
|
|
54
|
+
src={item.image}
|
|
55
|
+
alt={alt}
|
|
56
|
+
loading="lazy"
|
|
57
|
+
responsive={false}
|
|
58
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
59
|
+
/>
|
|
60
|
+
) : null}
|
|
61
|
+
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Two (or more) columns of photos drifting slowly in opposite directions on
|
|
68
|
+
* desktop; one continuous left-to-right strip on mobile. Optional editorial
|
|
69
|
+
* text column beside the marquee. Motion respects `prefers-reduced-motion`.
|
|
70
|
+
*/
|
|
71
|
+
export function ImageMarqueeBlock({ props }: { props: ImageMarqueeProps }) {
|
|
72
|
+
const { t } = useT();
|
|
73
|
+
const items = (props.items ?? []).filter((i) => i.image);
|
|
74
|
+
const columnCount = Math.min(3, Math.max(1, props.columns ?? 2));
|
|
75
|
+
const textPosition = props.textPosition ?? "left";
|
|
76
|
+
const hasText = textPosition !== "none";
|
|
77
|
+
const tone = TONE[props.tone ?? "white"] ?? "bg-white";
|
|
78
|
+
const gap = GAP[props.gap ?? "md"] ?? GAP.md;
|
|
79
|
+
const rounded = props.rounded ?? false;
|
|
80
|
+
const duration = SPEED_SECONDS[props.speed ?? "normal"] ?? SPEED_SECONDS.normal;
|
|
81
|
+
const firstUp = (props.firstDirection ?? "up") === "up";
|
|
82
|
+
const textItems = props.items2 ?? [];
|
|
83
|
+
|
|
84
|
+
// Images can be pinned to a specific column. Unpinned images fill the
|
|
85
|
+
// remaining columns (and any column left empty after pinning), so every
|
|
86
|
+
// configured column stays populated.
|
|
87
|
+
const hasPinned = items.some((i) => Number(i.column) >= 1);
|
|
88
|
+
const unpinned = items.filter((i) => !(Number(i.column) >= 1));
|
|
89
|
+
|
|
90
|
+
const columns: ImageMarqueeItem[][] = Array.from({ length: columnCount }, (_, columnIndex) => {
|
|
91
|
+
const pinned = hasPinned
|
|
92
|
+
? items.filter((i) => Number(i.column) === columnIndex + 1)
|
|
93
|
+
: [];
|
|
94
|
+
if (pinned.length > 0) return pinned;
|
|
95
|
+
const pool = unpinned.length > 0 ? unpinned : items;
|
|
96
|
+
if (pool.length === 0) return [];
|
|
97
|
+
return pool.map((_, i) => pool[(i + columnIndex) % pool.length]).filter(Boolean);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const aspectFor = (i: number) =>
|
|
101
|
+
(props.aspect ?? "portrait") === "mixed"
|
|
102
|
+
? MIXED_CYCLE[i % MIXED_CYCLE.length]
|
|
103
|
+
: ASPECT[props.aspect ?? "portrait"] ?? ASPECT.portrait;
|
|
104
|
+
|
|
105
|
+
const marquee = (
|
|
106
|
+
<div className="relative">
|
|
107
|
+
{/* Always honour the selected column count, including inside the builder. */}
|
|
108
|
+
<div
|
|
109
|
+
className={`grid ${gap} marquee-mask-y h-[clamp(520px,72vh,820px)] overflow-hidden`}
|
|
110
|
+
style={{ gridTemplateColumns: `repeat(${columnCount}, minmax(0, 1fr))` }}
|
|
111
|
+
>
|
|
112
|
+
{columns.map((col, ci) => {
|
|
113
|
+
const up = ci % 2 === 0 ? firstUp : !firstUp;
|
|
114
|
+
return (
|
|
115
|
+
<div key={ci} className="relative overflow-hidden">
|
|
116
|
+
<div
|
|
117
|
+
className={`flex flex-col ${gap} ${up ? "marquee-up" : "marquee-down"}`}
|
|
118
|
+
style={{ animationDuration: `${duration + ci * 6}s` }}
|
|
119
|
+
>
|
|
120
|
+
{[0, 1].map((dup) => (
|
|
121
|
+
<div key={dup} className={`flex flex-col ${gap}`} aria-hidden={dup === 1}>
|
|
122
|
+
{col.map((item, i) => (
|
|
123
|
+
<Tile
|
|
124
|
+
key={`${item.id ?? i}-${dup}`}
|
|
125
|
+
item={item}
|
|
126
|
+
aspectClass={aspectFor(i + ci)}
|
|
127
|
+
rounded={rounded}
|
|
128
|
+
/>
|
|
129
|
+
))}
|
|
130
|
+
</div>
|
|
131
|
+
))}
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
})}
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const text = hasText ? (
|
|
142
|
+
<div className="max-w-xl">
|
|
143
|
+
{props.eyebrow ? (
|
|
144
|
+
<span className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/70">
|
|
145
|
+
{t(props.eyebrow)}
|
|
146
|
+
</span>
|
|
147
|
+
) : null}
|
|
148
|
+
{props.title ? (
|
|
149
|
+
<h2 className="mt-4 font-serif text-[clamp(1.9rem,4vw,3.25rem)] leading-[1.05] tracking-[-0.02em] text-ink whitespace-pre-line">
|
|
150
|
+
{t(props.title)}
|
|
151
|
+
</h2>
|
|
152
|
+
) : null}
|
|
153
|
+
{props.lead ? (
|
|
154
|
+
<p className="mt-6 text-lg leading-relaxed text-ink/85 whitespace-pre-line">
|
|
155
|
+
{t(props.lead)}
|
|
156
|
+
</p>
|
|
157
|
+
) : null}
|
|
158
|
+
{textItems.length > 0 ? (
|
|
159
|
+
<div className="mt-10 space-y-8">
|
|
160
|
+
{textItems.map((item, i) => (
|
|
161
|
+
<article key={item.id ?? i} className="border-t border-ink/10 pt-6">
|
|
162
|
+
{item.heading ? (
|
|
163
|
+
<h3 className="font-serif text-xl md:text-2xl text-ink whitespace-pre-line">{t(item.heading)}</h3>
|
|
164
|
+
) : null}
|
|
165
|
+
{item.body ? (
|
|
166
|
+
<p className="mt-3 leading-relaxed text-ink/80 whitespace-pre-line">
|
|
167
|
+
{t(item.body)}
|
|
168
|
+
</p>
|
|
169
|
+
) : null}
|
|
170
|
+
</article>
|
|
171
|
+
))}
|
|
172
|
+
</div>
|
|
173
|
+
) : null}
|
|
174
|
+
</div>
|
|
175
|
+
) : null;
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<section id={props.anchor || undefined} className={`${tone} py-20 md:py-28 overflow-hidden`}>
|
|
179
|
+
<style>{`
|
|
180
|
+
@keyframes cms-marquee-up { from { transform: translateY(0); } to { transform: translateY(-50%); } }
|
|
181
|
+
@keyframes cms-marquee-down { from { transform: translateY(-50%); } to { transform: translateY(0); } }
|
|
182
|
+
@keyframes cms-marquee-x { from { transform: translateX(0); } to { transform: translateX(-50%); } }
|
|
183
|
+
.marquee-up { animation-name: cms-marquee-up; animation-timing-function: linear; animation-iteration-count: infinite; }
|
|
184
|
+
.marquee-down { animation-name: cms-marquee-down; animation-timing-function: linear; animation-iteration-count: infinite; }
|
|
185
|
+
.marquee-x { animation-name: cms-marquee-x; animation-timing-function: linear; animation-iteration-count: infinite; }
|
|
186
|
+
.marquee-up:hover, .marquee-down:hover, .marquee-x:hover { animation-play-state: paused; }
|
|
187
|
+
.marquee-mask-y { -webkit-mask-image: linear-gradient(to bottom, transparent, black 8%, black 92%, transparent); mask-image: linear-gradient(to bottom, transparent, black 8%, black 92%, transparent); }
|
|
188
|
+
.marquee-mask-x { -webkit-mask-image: linear-gradient(to right, transparent, black 6%, black 94%, transparent); mask-image: linear-gradient(to right, transparent, black 6%, black 94%, transparent); }
|
|
189
|
+
@media (prefers-reduced-motion: reduce) {
|
|
190
|
+
.marquee-up, .marquee-down, .marquee-x { animation: none !important; }
|
|
191
|
+
}
|
|
192
|
+
`}</style>
|
|
193
|
+
<div className="container-editorial">
|
|
194
|
+
{hasText ? (
|
|
195
|
+
<div
|
|
196
|
+
className={`grid items-center gap-12 lg:gap-16 lg:grid-cols-2 ${
|
|
197
|
+
textPosition === "right" ? "lg:[&>*:first-child]:order-2" : ""
|
|
198
|
+
}`}
|
|
199
|
+
>
|
|
200
|
+
<Reveal>{text}</Reveal>
|
|
201
|
+
<div className="min-w-0">{marquee}</div>
|
|
202
|
+
</div>
|
|
203
|
+
) : (
|
|
204
|
+
<div className="min-w-0">{marquee}</div>
|
|
205
|
+
)}
|
|
206
|
+
</div>
|
|
207
|
+
</section>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { Instagram as InstagramIcon, ExternalLink } from "lucide-react";
|
|
2
|
+
import { useT } from "../../i18n";
|
|
3
|
+
import type { InstagramProps, InstagramItem } from "../types";
|
|
4
|
+
import { FocalImage } from "../../host";
|
|
5
|
+
|
|
6
|
+
const GAP_CLASS: Record<NonNullable<InstagramProps["gap"]>, string> = {
|
|
7
|
+
none: "gap-0",
|
|
8
|
+
sm: "gap-1.5 md:gap-2",
|
|
9
|
+
md: "gap-3 md:gap-4",
|
|
10
|
+
lg: "gap-5 md:gap-8",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const ASPECT_CLASS: Record<NonNullable<InstagramProps["aspect"]>, string> = {
|
|
14
|
+
square: "aspect-square",
|
|
15
|
+
portrait: "aspect-[4/5]",
|
|
16
|
+
landscape: "aspect-[4/3]",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function clamp(n: number | undefined, min: number, max: number, fallback: number) {
|
|
20
|
+
const v = typeof n === "number" && Number.isFinite(n) ? n : fallback;
|
|
21
|
+
return Math.min(max, Math.max(min, v));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function Tile({
|
|
25
|
+
item,
|
|
26
|
+
aspect,
|
|
27
|
+
showOverlay,
|
|
28
|
+
}: {
|
|
29
|
+
item: InstagramItem;
|
|
30
|
+
aspect: NonNullable<InstagramProps["aspect"]>;
|
|
31
|
+
showOverlay: boolean;
|
|
32
|
+
}) {
|
|
33
|
+
const { t } = useT();
|
|
34
|
+
const alt = item.alt ? t(item.alt as Parameters<typeof t>[0]) : "";
|
|
35
|
+
const inner = (
|
|
36
|
+
<div className={`relative ${ASPECT_CLASS[aspect]} bg-ink/5 overflow-hidden group`}>
|
|
37
|
+
{item.image ? (
|
|
38
|
+
<FocalImage
|
|
39
|
+
src={item.image}
|
|
40
|
+
responsive={false}
|
|
41
|
+
alt={alt}
|
|
42
|
+
loading="lazy"
|
|
43
|
+
className="absolute inset-0 h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
44
|
+
/>
|
|
45
|
+
) : (
|
|
46
|
+
<div className="absolute inset-0 grid place-items-center text-ink/25">
|
|
47
|
+
<InstagramIcon className="size-8" />
|
|
48
|
+
</div>
|
|
49
|
+
)}
|
|
50
|
+
{showOverlay && item.image ? (
|
|
51
|
+
<div className="absolute inset-0 grid place-items-center bg-ink/0 group-hover:bg-ink/40 transition-colors">
|
|
52
|
+
<InstagramIcon className="size-6 text-paper opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
53
|
+
</div>
|
|
54
|
+
) : null}
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
if (item.href) {
|
|
59
|
+
return (
|
|
60
|
+
<a href={item.href} target="_blank" rel="noreferrer noopener" className="block">
|
|
61
|
+
{inner}
|
|
62
|
+
</a>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return inner;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function InstagramBlock({ props }: { props: InstagramProps }) {
|
|
69
|
+
const { t } = useT();
|
|
70
|
+
const layout = props.layout ?? "grid";
|
|
71
|
+
const cM = clamp(props.columnsMobile, 1, 4, 2);
|
|
72
|
+
const cT = clamp(props.columnsTablet, 2, 6, 3);
|
|
73
|
+
const cD = clamp(props.columnsDesktop, 2, 8, 4);
|
|
74
|
+
const rows = clamp(props.rows, 1, 8, 2);
|
|
75
|
+
const gap = GAP_CLASS[props.gap ?? "md"];
|
|
76
|
+
const aspect = props.aspect ?? "square";
|
|
77
|
+
const showOverlay = props.showOverlay ?? true;
|
|
78
|
+
const showHeader = props.showHeader ?? true;
|
|
79
|
+
|
|
80
|
+
const allItems = props.items ?? [];
|
|
81
|
+
const limit = layout === "grid" ? cD * rows : allItems.length;
|
|
82
|
+
const items = allItems.slice(0, limit);
|
|
83
|
+
|
|
84
|
+
const handle = (props.handle ?? "").replace(/^@/, "");
|
|
85
|
+
const profileHref =
|
|
86
|
+
props.profileHref ?? (handle ? `https://instagram.com/${handle}` : undefined);
|
|
87
|
+
|
|
88
|
+
// Inline styles for arbitrary column counts (Tailwind JIT can't infer runtime numbers).
|
|
89
|
+
const gridStyle = {
|
|
90
|
+
display: "grid",
|
|
91
|
+
gridTemplateColumns: `repeat(var(--ig-cols, ${cM}), minmax(0, 1fr))`,
|
|
92
|
+
} as React.CSSProperties;
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<section className="bg-white py-20 md:py-28">
|
|
96
|
+
<div className="mx-auto max-w-7xl px-6">
|
|
97
|
+
{showHeader ? (
|
|
98
|
+
<div className="mb-10 md:mb-14 flex flex-wrap items-end justify-between gap-4">
|
|
99
|
+
<div>
|
|
100
|
+
{props.title ? (
|
|
101
|
+
<h2 className="font-serif text-3xl md:text-5xl text-ink whitespace-pre-line">
|
|
102
|
+
{t(props.title as Parameters<typeof t>[0])}
|
|
103
|
+
</h2>
|
|
104
|
+
) : null}
|
|
105
|
+
{handle ? (
|
|
106
|
+
<div className="mt-2 text-sm text-ink/60 flex items-center gap-1.5">
|
|
107
|
+
<InstagramIcon className="size-4" />
|
|
108
|
+
<span>@{handle}</span>
|
|
109
|
+
</div>
|
|
110
|
+
) : null}
|
|
111
|
+
</div>
|
|
112
|
+
{profileHref ? (
|
|
113
|
+
<a
|
|
114
|
+
href={profileHref}
|
|
115
|
+
target="_blank"
|
|
116
|
+
rel="noreferrer noopener"
|
|
117
|
+
className="inline-flex items-center gap-2 border border-ink/20 px-4 py-2 text-sm text-ink hover:border-sea hover:text-sea transition-colors"
|
|
118
|
+
>
|
|
119
|
+
Follow <ExternalLink className="size-3.5" />
|
|
120
|
+
</a>
|
|
121
|
+
) : null}
|
|
122
|
+
</div>
|
|
123
|
+
) : null}
|
|
124
|
+
|
|
125
|
+
{layout === "carousel" ? (
|
|
126
|
+
<div className={`flex overflow-x-auto snap-x snap-mandatory ${gap} -mx-6 px-6 pb-2`}>
|
|
127
|
+
{items.map((item) => (
|
|
128
|
+
<div
|
|
129
|
+
key={item.id}
|
|
130
|
+
className="snap-start shrink-0"
|
|
131
|
+
style={{ width: `min(80vw, ${100 / cD}%)` }}
|
|
132
|
+
>
|
|
133
|
+
<Tile item={item} aspect={aspect} showOverlay={showOverlay} />
|
|
134
|
+
</div>
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
) : (
|
|
138
|
+
<>
|
|
139
|
+
<style>{`
|
|
140
|
+
.ig-grid { --ig-cols: ${cM}; }
|
|
141
|
+
@media (min-width: 768px) { .ig-grid { --ig-cols: ${cT}; } }
|
|
142
|
+
@media (min-width: 1024px) { .ig-grid { --ig-cols: ${cD}; } }
|
|
143
|
+
`}</style>
|
|
144
|
+
<div className={`ig-grid ${gap}`} style={gridStyle}>
|
|
145
|
+
{items.map((item) => (
|
|
146
|
+
<Tile key={item.id} item={item} aspect={aspect} showOverlay={showOverlay} />
|
|
147
|
+
))}
|
|
148
|
+
</div>
|
|
149
|
+
</>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
{items.length === 0 ? (
|
|
153
|
+
<div className="mt-8 border border-dashed border-ink/15 p-10 text-center text-ink/70 text-sm">
|
|
154
|
+
No Instagram posts yet. Add images in the block inspector.
|
|
155
|
+
</div>
|
|
156
|
+
) : null}
|
|
157
|
+
</div>
|
|
158
|
+
</section>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { useT } from "../../i18n";
|
|
2
|
+
import { Reveal } from "../../ui/Reveal";
|
|
3
|
+
import { FocalImage } from "../../host";
|
|
4
|
+
import type { LinkTilesProps, LinkTilesItem } from "../types";
|
|
5
|
+
|
|
6
|
+
const TONE: Record<string, string> = {
|
|
7
|
+
white: "bg-white",
|
|
8
|
+
paper: "bg-paper",
|
|
9
|
+
ice: "bg-ice",
|
|
10
|
+
ink: "bg-ink",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const GAP: Record<string, string> = {
|
|
14
|
+
sm: "gap-3",
|
|
15
|
+
md: "gap-5",
|
|
16
|
+
lg: "gap-8",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const ASPECT: Record<string, string> = {
|
|
20
|
+
portrait: "aspect-[3/4]",
|
|
21
|
+
square: "aspect-square",
|
|
22
|
+
landscape: "aspect-[4/3]",
|
|
23
|
+
tall: "aspect-[2/3]",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const COLS: Record<number, string> = {
|
|
27
|
+
1: "sm:grid-cols-1",
|
|
28
|
+
2: "sm:grid-cols-2",
|
|
29
|
+
3: "sm:grid-cols-2 lg:grid-cols-3",
|
|
30
|
+
4: "sm:grid-cols-2 lg:grid-cols-4",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function Tile({
|
|
34
|
+
item,
|
|
35
|
+
aspectClass,
|
|
36
|
+
rounded,
|
|
37
|
+
overlay,
|
|
38
|
+
labelAlign,
|
|
39
|
+
}: {
|
|
40
|
+
item: LinkTilesItem;
|
|
41
|
+
aspectClass: string;
|
|
42
|
+
rounded: boolean;
|
|
43
|
+
overlay: "none" | "light" | "medium" | "strong" | "brand";
|
|
44
|
+
labelAlign: "left" | "center";
|
|
45
|
+
}) {
|
|
46
|
+
const { t } = useT();
|
|
47
|
+
const label = t(item.label);
|
|
48
|
+
const alt = item.alt ? t(item.alt) : label;
|
|
49
|
+
const overlayClass =
|
|
50
|
+
overlay === "none"
|
|
51
|
+
? ""
|
|
52
|
+
: overlay === "brand"
|
|
53
|
+
? "bg-gradient-to-t from-sea/70 via-ink/35 to-transparent mix-blend-multiply"
|
|
54
|
+
: overlay === "light"
|
|
55
|
+
? "bg-gradient-to-t from-ink/55 via-ink/10 to-transparent"
|
|
56
|
+
: overlay === "strong"
|
|
57
|
+
? "bg-gradient-to-t from-ink/90 via-ink/45 to-ink/10"
|
|
58
|
+
: "bg-gradient-to-t from-ink/80 via-ink/30 to-transparent";
|
|
59
|
+
|
|
60
|
+
const inner = (
|
|
61
|
+
<>
|
|
62
|
+
{item.image ? (
|
|
63
|
+
<FocalImage
|
|
64
|
+
src={item.image}
|
|
65
|
+
alt={alt}
|
|
66
|
+
loading="lazy"
|
|
67
|
+
sizes="(max-width: 640px) 90vw, (max-width: 1024px) 45vw, 30vw"
|
|
68
|
+
className="absolute inset-0 h-full w-full object-cover transition-transform duration-700 group-hover:scale-[1.04]"
|
|
69
|
+
/>
|
|
70
|
+
) : null}
|
|
71
|
+
{overlay !== "none" ? <div className={`absolute inset-0 ${overlayClass}`} /> : null}
|
|
72
|
+
<div
|
|
73
|
+
className={`relative flex h-full flex-col justify-end p-6 md:p-7 ${
|
|
74
|
+
labelAlign === "center" ? "items-center text-center" : "items-start"
|
|
75
|
+
}`}
|
|
76
|
+
>
|
|
77
|
+
{item.eyebrow ? (
|
|
78
|
+
<span className="text-[0.65rem] uppercase tracking-[0.22em] text-paper/75">
|
|
79
|
+
{t(item.eyebrow)}
|
|
80
|
+
</span>
|
|
81
|
+
) : null}
|
|
82
|
+
{label ? (
|
|
83
|
+
<span className="mt-3 inline-flex items-center justify-center bg-sea px-6 py-3 text-xs uppercase tracking-[0.22em] text-ink shadow-lg transition-colors hover:bg-ink hover:text-paper">
|
|
84
|
+
{label}
|
|
85
|
+
</span>
|
|
86
|
+
) : null}
|
|
87
|
+
{item.body ? (
|
|
88
|
+
<span className="mt-3 max-w-[26ch] text-sm leading-relaxed text-paper/85">
|
|
89
|
+
{t(item.body)}
|
|
90
|
+
</span>
|
|
91
|
+
) : null}
|
|
92
|
+
</div>
|
|
93
|
+
</>
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const frame = `group relative block w-full overflow-hidden bg-ink/10 ${aspectClass} ${
|
|
97
|
+
rounded ? "rounded-2xl" : ""
|
|
98
|
+
}`;
|
|
99
|
+
|
|
100
|
+
if (!item.href) return <div className={frame}>{inner}</div>;
|
|
101
|
+
|
|
102
|
+
const external = /^https?:\/\//i.test(item.href);
|
|
103
|
+
return (
|
|
104
|
+
<a
|
|
105
|
+
href={item.href}
|
|
106
|
+
{...(external ? { target: "_blank", rel: "noreferrer" } : {})}
|
|
107
|
+
className={frame}
|
|
108
|
+
>
|
|
109
|
+
{inner}
|
|
110
|
+
</a>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Row of linked image tiles with an overlay label.
|
|
116
|
+
* Every tile's image, text and link is editable in the backoffice.
|
|
117
|
+
*/
|
|
118
|
+
export function LinkTilesBlock({ props }: { props: LinkTilesProps }) {
|
|
119
|
+
const { t } = useT();
|
|
120
|
+
const items = props.items ?? [];
|
|
121
|
+
const tone = TONE[props.tone ?? "white"] ?? "bg-white";
|
|
122
|
+
const dark = (props.tone ?? "white") === "ink";
|
|
123
|
+
const gap = GAP[props.gap ?? "md"] ?? GAP.md;
|
|
124
|
+
const aspect = ASPECT[props.aspect ?? "portrait"] ?? ASPECT.portrait;
|
|
125
|
+
const cols = COLS[props.columns ?? 3] ?? COLS[3];
|
|
126
|
+
|
|
127
|
+
if (items.length === 0) return null;
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<section id={props.anchor || undefined} className={`${tone} py-16 md:py-24`}>
|
|
131
|
+
<div className="container-editorial">
|
|
132
|
+
{props.eyebrow || props.title || props.lead ? (
|
|
133
|
+
<Reveal>
|
|
134
|
+
<div className="mb-10 max-w-2xl">
|
|
135
|
+
{props.eyebrow ? (
|
|
136
|
+
<span
|
|
137
|
+
className={`text-[0.7rem] uppercase tracking-[0.22em] ${
|
|
138
|
+
dark ? "text-paper/70" : "text-ink/60"
|
|
139
|
+
}`}
|
|
140
|
+
>
|
|
141
|
+
{t(props.eyebrow)}
|
|
142
|
+
</span>
|
|
143
|
+
) : null}
|
|
144
|
+
{props.title ? (
|
|
145
|
+
<h2
|
|
146
|
+
className={`mt-3 font-serif text-[clamp(1.7rem,3.4vw,2.75rem)] leading-[1.08] ${
|
|
147
|
+
dark ? "text-paper" : "text-ink"
|
|
148
|
+
}`}
|
|
149
|
+
>
|
|
150
|
+
{t(props.title)}
|
|
151
|
+
</h2>
|
|
152
|
+
) : null}
|
|
153
|
+
{props.lead ? (
|
|
154
|
+
<p
|
|
155
|
+
className={`mt-4 text-base leading-relaxed ${
|
|
156
|
+
dark ? "text-paper/80" : "text-ink/70"
|
|
157
|
+
}`}
|
|
158
|
+
>
|
|
159
|
+
{t(props.lead)}
|
|
160
|
+
</p>
|
|
161
|
+
) : null}
|
|
162
|
+
</div>
|
|
163
|
+
</Reveal>
|
|
164
|
+
) : null}
|
|
165
|
+
<Reveal>
|
|
166
|
+
<div className={`grid grid-cols-1 ${cols} ${gap}`}>
|
|
167
|
+
{items.map((item, i) => (
|
|
168
|
+
<Tile
|
|
169
|
+
key={item.id ?? i}
|
|
170
|
+
item={item}
|
|
171
|
+
aspectClass={aspect}
|
|
172
|
+
rounded={props.rounded ?? false}
|
|
173
|
+
overlay={props.overlay ?? "medium"}
|
|
174
|
+
labelAlign={props.labelAlign ?? "left"}
|
|
175
|
+
/>
|
|
176
|
+
))}
|
|
177
|
+
</div>
|
|
178
|
+
</Reveal>
|
|
179
|
+
</div>
|
|
180
|
+
</section>
|
|
181
|
+
);
|
|
182
|
+
}
|