@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,436 @@
|
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
import { toast } from "sonner";
|
|
4
|
+
import { X, Image as ImageIcon, Upload, Loader2 } from "lucide-react";
|
|
5
|
+
|
|
6
|
+
import type { LocalizedString } from "../../i18n";
|
|
7
|
+
import { SUPPORTED_LOCALES, LOCALE_LABELS, type Locale } from "../../i18n";
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
FocalImage,
|
|
12
|
+
FocusPicker,
|
|
13
|
+
useHostMediaLibraryOptions,
|
|
14
|
+
useHostMediaUpload,
|
|
15
|
+
type HostMediaItem as MediaItem,
|
|
16
|
+
} from "../../host";
|
|
17
|
+
|
|
18
|
+
type LocValue = Partial<LocalizedString> | undefined;
|
|
19
|
+
|
|
20
|
+
export function LocalizedField({
|
|
21
|
+
label,
|
|
22
|
+
value,
|
|
23
|
+
onChange,
|
|
24
|
+
multiline,
|
|
25
|
+
placeholder,
|
|
26
|
+
}: {
|
|
27
|
+
label: string;
|
|
28
|
+
value: LocValue;
|
|
29
|
+
onChange: (next: Partial<LocalizedString>) => void;
|
|
30
|
+
multiline?: boolean;
|
|
31
|
+
placeholder?: Partial<Record<Locale, string>>;
|
|
32
|
+
}) {
|
|
33
|
+
const v = (value ?? {}) as Record<string, string>;
|
|
34
|
+
return (
|
|
35
|
+
<div className="space-y-1.5">
|
|
36
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
|
|
37
|
+
{label}
|
|
38
|
+
</div>
|
|
39
|
+
<div className="space-y-2">
|
|
40
|
+
{SUPPORTED_LOCALES.map((loc) => {
|
|
41
|
+
const commonProps = {
|
|
42
|
+
value: v[loc] ?? "",
|
|
43
|
+
placeholder: placeholder?.[loc] ?? "",
|
|
44
|
+
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
|
45
|
+
onChange({ ...v, [loc]: e.target.value } as Partial<LocalizedString>),
|
|
46
|
+
className:
|
|
47
|
+
"w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea",
|
|
48
|
+
};
|
|
49
|
+
return (
|
|
50
|
+
<div key={loc} className="flex gap-2 items-start">
|
|
51
|
+
<span className="mt-1.5 shrink-0 text-[0.65rem] uppercase tracking-widest text-ink/45 w-8">
|
|
52
|
+
{LOCALE_LABELS[loc] ?? loc}
|
|
53
|
+
</span>
|
|
54
|
+
{multiline ? (
|
|
55
|
+
<textarea rows={3} {...commonProps} />
|
|
56
|
+
) : (
|
|
57
|
+
<input type="text" {...commonProps} />
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
})}
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function TextField({
|
|
68
|
+
label,
|
|
69
|
+
value,
|
|
70
|
+
onChange,
|
|
71
|
+
placeholder,
|
|
72
|
+
}: {
|
|
73
|
+
label: string;
|
|
74
|
+
value: string | undefined;
|
|
75
|
+
onChange: (v: string) => void;
|
|
76
|
+
placeholder?: string;
|
|
77
|
+
}) {
|
|
78
|
+
return (
|
|
79
|
+
<label className="block space-y-1.5">
|
|
80
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
|
|
81
|
+
{label}
|
|
82
|
+
</span>
|
|
83
|
+
<input
|
|
84
|
+
type="text"
|
|
85
|
+
value={value ?? ""}
|
|
86
|
+
placeholder={placeholder}
|
|
87
|
+
onChange={(e) => onChange(e.target.value)}
|
|
88
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
89
|
+
/>
|
|
90
|
+
</label>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function InspectorSection({
|
|
95
|
+
title,
|
|
96
|
+
children,
|
|
97
|
+
}: {
|
|
98
|
+
title: string;
|
|
99
|
+
children: React.ReactNode;
|
|
100
|
+
}) {
|
|
101
|
+
return (
|
|
102
|
+
<section className="space-y-3 border-b border-ink/10 pb-5 last:border-b-0 last:pb-0">
|
|
103
|
+
<div className="text-xs font-medium uppercase tracking-[0.18em] text-ink">
|
|
104
|
+
{title}
|
|
105
|
+
</div>
|
|
106
|
+
<div className="space-y-4">{children}</div>
|
|
107
|
+
</section>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/* ---------------- Media picker ---------------- */
|
|
112
|
+
|
|
113
|
+
export function MediaPickerDialog({
|
|
114
|
+
accept,
|
|
115
|
+
onPick,
|
|
116
|
+
onPickMany,
|
|
117
|
+
onClose,
|
|
118
|
+
multiple,
|
|
119
|
+
}: {
|
|
120
|
+
accept?: "image" | "video" | "any";
|
|
121
|
+
onPick: (item: MediaItem) => void;
|
|
122
|
+
onPickMany?: (items: MediaItem[]) => void;
|
|
123
|
+
onClose: () => void;
|
|
124
|
+
multiple?: boolean;
|
|
125
|
+
}) {
|
|
126
|
+
const mediaLibraryOptions = useHostMediaLibraryOptions();
|
|
127
|
+
const { data = [], isLoading } = useQuery(mediaLibraryOptions);
|
|
128
|
+
const [q, setQ] = useState("");
|
|
129
|
+
const [selected, setSelected] = useState<MediaItem[]>([]);
|
|
130
|
+
const fileInput = useRef<HTMLInputElement>(null);
|
|
131
|
+
const { upload, uploading, progress } = useHostMediaUpload(data);
|
|
132
|
+
const acceptAttr = accept === "image" ? "image/*" : accept === "video" ? "video/*" : undefined;
|
|
133
|
+
|
|
134
|
+
const handleFiles = async (files: FileList | null) => {
|
|
135
|
+
if (!files?.length) return;
|
|
136
|
+
try {
|
|
137
|
+
const urls = await upload(files);
|
|
138
|
+
if (fileInput.current) fileInput.current.value = "";
|
|
139
|
+
const items = urls.map((last) => {
|
|
140
|
+
const name = decodeURIComponent(last.split("/").pop() ?? "");
|
|
141
|
+
return {
|
|
142
|
+
name,
|
|
143
|
+
path: name,
|
|
144
|
+
size: 0,
|
|
145
|
+
contentType: /\.(mp4|webm|mov)$/i.test(name) ? "video/mp4" : "image/webp",
|
|
146
|
+
updatedAt: null,
|
|
147
|
+
url: last,
|
|
148
|
+
} as MediaItem;
|
|
149
|
+
});
|
|
150
|
+
if (!items.length) return;
|
|
151
|
+
toast.success(`Uploaded ${items.length} file${items.length > 1 ? "s" : ""}`);
|
|
152
|
+
if (multiple) {
|
|
153
|
+
setSelected((prev) => {
|
|
154
|
+
const seen = new Set(prev.map((m) => m.path));
|
|
155
|
+
return [...prev, ...items.filter((m) => !seen.has(m.path))];
|
|
156
|
+
});
|
|
157
|
+
} else {
|
|
158
|
+
onPick(items[items.length - 1]);
|
|
159
|
+
onClose();
|
|
160
|
+
}
|
|
161
|
+
} catch (e: any) {
|
|
162
|
+
toast.error(e?.message ?? "Upload failed");
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const toggle = (m: MediaItem) => {
|
|
167
|
+
if (!multiple) {
|
|
168
|
+
onPick(m);
|
|
169
|
+
onClose();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
setSelected((prev) =>
|
|
173
|
+
prev.some((x) => x.path === m.path)
|
|
174
|
+
? prev.filter((x) => x.path !== m.path)
|
|
175
|
+
: [...prev, m],
|
|
176
|
+
);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const confirm = () => {
|
|
180
|
+
if (onPickMany) onPickMany(selected);
|
|
181
|
+
else selected.forEach(onPick);
|
|
182
|
+
onClose();
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
const filtered = data.filter((m) => {
|
|
187
|
+
if (accept === "image" && !(m.contentType ?? "").startsWith("image/")) return false;
|
|
188
|
+
if (accept === "video" && !(m.contentType ?? "").startsWith("video/")) return false;
|
|
189
|
+
if (q && !m.name.toLowerCase().includes(q.toLowerCase())) return false;
|
|
190
|
+
return true;
|
|
191
|
+
});
|
|
192
|
+
return (
|
|
193
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onClick={onClose}>
|
|
194
|
+
<div
|
|
195
|
+
className="w-full max-w-4xl max-h-[85vh] bg-white border border-ink/15 flex flex-col"
|
|
196
|
+
onClick={(e) => e.stopPropagation()}
|
|
197
|
+
>
|
|
198
|
+
<div className="flex items-center gap-2 border-b border-ink/10 p-3">
|
|
199
|
+
<ImageIcon className="size-4 text-ink/60" />
|
|
200
|
+
<span className="text-sm font-medium text-ink">Media library</span>
|
|
201
|
+
<input
|
|
202
|
+
value={q}
|
|
203
|
+
onChange={(e) => setQ(e.target.value)}
|
|
204
|
+
placeholder="Search…"
|
|
205
|
+
className="ml-3 flex-1 border border-ink/15 px-2 py-1 text-sm focus:outline-none focus:border-sea"
|
|
206
|
+
/>
|
|
207
|
+
<input
|
|
208
|
+
ref={fileInput}
|
|
209
|
+
type="file"
|
|
210
|
+
multiple
|
|
211
|
+
accept={acceptAttr}
|
|
212
|
+
className="hidden"
|
|
213
|
+
onChange={(e) => handleFiles(e.target.files)}
|
|
214
|
+
/>
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
disabled={uploading}
|
|
218
|
+
onClick={() => fileInput.current?.click()}
|
|
219
|
+
className="inline-flex items-center gap-1.5 border border-ink/20 px-3 py-1 text-xs uppercase tracking-widest text-ink hover:border-sea disabled:opacity-50"
|
|
220
|
+
>
|
|
221
|
+
{uploading ? <Loader2 className="size-3.5 animate-spin" /> : <Upload className="size-3.5" />}
|
|
222
|
+
{uploading ? "Uploading…" : "Upload"}
|
|
223
|
+
</button>
|
|
224
|
+
<button onClick={onClose} className="text-ink/50 hover:text-ink p-1"><X className="size-4" /></button>
|
|
225
|
+
</div>
|
|
226
|
+
{progress ? (
|
|
227
|
+
<div className="border-b border-ink/10 px-3 py-2">
|
|
228
|
+
<div className="mb-1 flex items-center justify-between text-[0.7rem] text-ink/60">
|
|
229
|
+
<span className="truncate">{progress.label}</span>
|
|
230
|
+
<span>{progress.percent}%</span>
|
|
231
|
+
</div>
|
|
232
|
+
<div className="h-1.5 w-full bg-ice/60 overflow-hidden">
|
|
233
|
+
<div
|
|
234
|
+
className="h-full bg-sea transition-all duration-200"
|
|
235
|
+
style={{ width: `${progress.percent}%` }}
|
|
236
|
+
/>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
) : null}
|
|
240
|
+
<div className="flex-1 overflow-auto p-3">
|
|
241
|
+
{isLoading ? (
|
|
242
|
+
<div className="text-sm text-ink/60">Loading…</div>
|
|
243
|
+
) : filtered.length === 0 ? (
|
|
244
|
+
<div className="text-sm text-ink/60">No media yet — use Upload above to add files.</div>
|
|
245
|
+
|
|
246
|
+
) : (
|
|
247
|
+
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
|
248
|
+
{filtered.map((m) => {
|
|
249
|
+
const isImg = (m.contentType ?? "").startsWith("image/");
|
|
250
|
+
const isVid = (m.contentType ?? "").startsWith("video/");
|
|
251
|
+
const isSel = multiple && selected.some((x) => x.path === m.path);
|
|
252
|
+
return (
|
|
253
|
+
<button
|
|
254
|
+
key={m.path}
|
|
255
|
+
onClick={() => toggle(m)}
|
|
256
|
+
className={`group border text-left relative ${
|
|
257
|
+
isSel ? "border-sea ring-2 ring-sea/40" : "border-ink/10 hover:border-sea"
|
|
258
|
+
}`}
|
|
259
|
+
>
|
|
260
|
+
{isSel && (
|
|
261
|
+
<span className="absolute top-2 right-2 z-10 flex size-5 items-center justify-center bg-sea text-ink text-xs font-bold">✓</span>
|
|
262
|
+
)}
|
|
263
|
+
<div className="aspect-video bg-ice/40 flex items-center justify-center overflow-hidden">
|
|
264
|
+
{isImg ? (
|
|
265
|
+
<img src={m.url} alt="" className="h-full w-full object-cover" />
|
|
266
|
+
) : isVid ? (
|
|
267
|
+
<video src={m.url} muted className="h-full w-full object-cover" />
|
|
268
|
+
) : (
|
|
269
|
+
<span className="text-xs text-ink/50">{m.contentType ?? "file"}</span>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
<div className="px-2 py-1.5 text-[0.7rem] text-ink truncate">{m.name}</div>
|
|
273
|
+
</button>
|
|
274
|
+
);
|
|
275
|
+
})}
|
|
276
|
+
</div>
|
|
277
|
+
)}
|
|
278
|
+
</div>
|
|
279
|
+
{multiple && (
|
|
280
|
+
<div className="flex items-center justify-end gap-2 border-t border-ink/10 p-3">
|
|
281
|
+
<span className="text-xs text-ink/60">{selected.length} selected</span>
|
|
282
|
+
<button
|
|
283
|
+
type="button"
|
|
284
|
+
onClick={confirm}
|
|
285
|
+
disabled={selected.length === 0}
|
|
286
|
+
className="inline-flex items-center gap-1.5 bg-ink px-4 py-1.5 text-xs uppercase tracking-widest text-white hover:bg-sea hover:text-ink disabled:opacity-40"
|
|
287
|
+
>
|
|
288
|
+
Add selected
|
|
289
|
+
</button>
|
|
290
|
+
</div>
|
|
291
|
+
)}
|
|
292
|
+
</div>
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function MediaField({
|
|
298
|
+
label,
|
|
299
|
+
value,
|
|
300
|
+
onChange,
|
|
301
|
+
accept = "any",
|
|
302
|
+
placeholder,
|
|
303
|
+
}: {
|
|
304
|
+
label: string;
|
|
305
|
+
value: string | undefined;
|
|
306
|
+
onChange: (url: string) => void;
|
|
307
|
+
accept?: "image" | "video" | "any";
|
|
308
|
+
placeholder?: string;
|
|
309
|
+
}) {
|
|
310
|
+
const [open, setOpen] = useState(false);
|
|
311
|
+
const [focusOpen, setFocusOpen] = useState(false);
|
|
312
|
+
const isImg = value && /\.(png|jpe?g|gif|webp|avif|svg)(\?|$)/i.test(value);
|
|
313
|
+
const isVid = value && /\.(mp4|webm|mov)(\?|$)/i.test(value);
|
|
314
|
+
return (
|
|
315
|
+
<div className="space-y-1.5">
|
|
316
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</div>
|
|
317
|
+
{value ? (
|
|
318
|
+
<div className="border border-ink/10 bg-ice/30 aspect-video overflow-hidden flex items-center justify-center">
|
|
319
|
+
{isImg ? (
|
|
320
|
+
<FocalImage src={value} responsive={false} alt="" className="h-full w-full object-cover" />
|
|
321
|
+
) : isVid ? (
|
|
322
|
+
<video src={value} muted className="h-full w-full object-cover" />
|
|
323
|
+
) : (
|
|
324
|
+
<span className="text-xs text-ink/50 p-2 break-all">{value}</span>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
) : null}
|
|
328
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
329
|
+
<input
|
|
330
|
+
type="text"
|
|
331
|
+
value={value ?? ""}
|
|
332
|
+
placeholder={placeholder ?? "https://…"}
|
|
333
|
+
onChange={(e) => onChange(e.target.value)}
|
|
334
|
+
className="min-w-0 flex-1 basis-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea"
|
|
335
|
+
/>
|
|
336
|
+
|
|
337
|
+
<button
|
|
338
|
+
type="button"
|
|
339
|
+
onClick={() => setOpen(true)}
|
|
340
|
+
className="border border-ink/20 px-3 py-1.5 text-xs uppercase tracking-widest text-ink hover:border-sea"
|
|
341
|
+
>
|
|
342
|
+
Library
|
|
343
|
+
</button>
|
|
344
|
+
{isImg ? (
|
|
345
|
+
<button
|
|
346
|
+
type="button"
|
|
347
|
+
onClick={() => setFocusOpen(true)}
|
|
348
|
+
className="border border-ink/20 px-3 py-1.5 text-xs uppercase tracking-widest text-ink hover:border-sea"
|
|
349
|
+
title="Choose which part of the image stays visible when cropped"
|
|
350
|
+
>
|
|
351
|
+
Focus
|
|
352
|
+
</button>
|
|
353
|
+
) : null}
|
|
354
|
+
{value ? (
|
|
355
|
+
<button
|
|
356
|
+
type="button"
|
|
357
|
+
onClick={() => onChange("")}
|
|
358
|
+
className="border border-ink/20 px-2 py-1.5 text-ink/50 hover:text-red-600"
|
|
359
|
+
title="Clear"
|
|
360
|
+
><X className="size-4" /></button>
|
|
361
|
+
) : null}
|
|
362
|
+
</div>
|
|
363
|
+
{open && (
|
|
364
|
+
<MediaPickerDialog
|
|
365
|
+
accept={accept}
|
|
366
|
+
onPick={(m) => onChange(m.url)}
|
|
367
|
+
onClose={() => setOpen(false)}
|
|
368
|
+
/>
|
|
369
|
+
)}
|
|
370
|
+
{focusOpen && value ? (
|
|
371
|
+
<FocusPicker
|
|
372
|
+
src={value}
|
|
373
|
+
modal
|
|
374
|
+
title={value.split("/").pop()}
|
|
375
|
+
onClose={() => setFocusOpen(false)}
|
|
376
|
+
/>
|
|
377
|
+
) : null}
|
|
378
|
+
|
|
379
|
+
</div>
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function MediaMultiField({
|
|
384
|
+
label,
|
|
385
|
+
values,
|
|
386
|
+
onChange,
|
|
387
|
+
accept = "image",
|
|
388
|
+
}: {
|
|
389
|
+
label: string;
|
|
390
|
+
values: string[];
|
|
391
|
+
onChange: (next: string[]) => void;
|
|
392
|
+
accept?: "image" | "video" | "any";
|
|
393
|
+
}) {
|
|
394
|
+
const [open, setOpen] = useState(false);
|
|
395
|
+
const add = (items: MediaItem[]) => {
|
|
396
|
+
const seen = new Set(values);
|
|
397
|
+
const fresh = items.filter((m) => m.url && !seen.has(m.url)).map((m) => m.url);
|
|
398
|
+
if (fresh.length) onChange([...values, ...fresh]);
|
|
399
|
+
};
|
|
400
|
+
return (
|
|
401
|
+
<div className="space-y-2">
|
|
402
|
+
<div className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</div>
|
|
403
|
+
<div className="flex flex-wrap gap-2">
|
|
404
|
+
{values.map((url, i) => (
|
|
405
|
+
<div key={`${url}-${i}`} className="relative group border border-ink/10">
|
|
406
|
+
<img src={url} alt="" className="size-16 object-cover" />
|
|
407
|
+
<button
|
|
408
|
+
type="button"
|
|
409
|
+
onClick={() => onChange(values.filter((_, j) => j !== i))}
|
|
410
|
+
className="absolute -top-1.5 -right-1.5 flex size-5 items-center justify-center bg-ink text-white hover:bg-red-600"
|
|
411
|
+
title="Remove"
|
|
412
|
+
><X className="size-3" /></button>
|
|
413
|
+
</div>
|
|
414
|
+
))}
|
|
415
|
+
<button
|
|
416
|
+
type="button"
|
|
417
|
+
onClick={() => setOpen(true)}
|
|
418
|
+
className="flex size-16 items-center justify-center border border-dashed border-ink/30 text-ink/50 hover:border-sea hover:text-ink"
|
|
419
|
+
title="Add images"
|
|
420
|
+
>
|
|
421
|
+
<ImageIcon className="size-5" />
|
|
422
|
+
</button>
|
|
423
|
+
</div>
|
|
424
|
+
{open && (
|
|
425
|
+
<MediaPickerDialog
|
|
426
|
+
accept={accept}
|
|
427
|
+
multiple
|
|
428
|
+
onPick={(m) => add([m])}
|
|
429
|
+
onPickMany={(items) => add(items)}
|
|
430
|
+
onClose={() => setOpen(false)}
|
|
431
|
+
/>
|
|
432
|
+
)}
|
|
433
|
+
|
|
434
|
+
</div>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
import type { BlockProps } from "./types";
|
|
3
|
+
import type { LocalizedString } from "../i18n";
|
|
4
|
+
|
|
5
|
+
const Ctx = createContext<BlockProps>({});
|
|
6
|
+
|
|
7
|
+
export function BlockPropsProvider({
|
|
8
|
+
value,
|
|
9
|
+
children,
|
|
10
|
+
}: {
|
|
11
|
+
value: BlockProps | undefined;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
}) {
|
|
14
|
+
return <Ctx.Provider value={value ?? {}}>{children}</Ctx.Provider>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useBlockProps<T = BlockProps>(): T {
|
|
18
|
+
return useContext(Ctx) as unknown as T;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Merge an override localized string over a base one. Only non-empty
|
|
23
|
+
* string values in the override are applied; missing/empty locales fall
|
|
24
|
+
* back to the base value so admins can translate only what they need.
|
|
25
|
+
*/
|
|
26
|
+
export function mergeLocalized(
|
|
27
|
+
override: Partial<LocalizedString> | undefined | null,
|
|
28
|
+
base: LocalizedString,
|
|
29
|
+
): LocalizedString {
|
|
30
|
+
if (!override) return base;
|
|
31
|
+
const out: Record<string, string> = { ...(base as unknown as Record<string, string>) };
|
|
32
|
+
for (const [k, v] of Object.entries(override)) {
|
|
33
|
+
if (typeof v === "string" && v.trim() !== "") out[k] = v;
|
|
34
|
+
}
|
|
35
|
+
return out as unknown as LocalizedString;
|
|
36
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Check, ChevronLeft, ChevronRight } from "lucide-react";
|
|
3
|
+
import { useT } from "../../i18n";
|
|
4
|
+
import { Reveal } from "../../ui/Reveal";
|
|
5
|
+
import { FocalImage } from "../../host";
|
|
6
|
+
import type { CabinTabsProps } from "../types";
|
|
7
|
+
|
|
8
|
+
const TONE: Record<string, string> = {
|
|
9
|
+
white: "bg-white",
|
|
10
|
+
paper: "bg-paper",
|
|
11
|
+
ice: "bg-ice",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Cabin categories as clickable tabs — each tab keeps its own description,
|
|
16
|
+
* bullet list and image gallery. Everything is editable in the page builder.
|
|
17
|
+
*/
|
|
18
|
+
export function CabinTabsBlock({ props }: { props: CabinTabsProps }) {
|
|
19
|
+
const { t } = useT();
|
|
20
|
+
const items = props.items ?? [];
|
|
21
|
+
const [active, setActive] = useState(0);
|
|
22
|
+
const [slide, setSlide] = useState(0);
|
|
23
|
+
|
|
24
|
+
if (items.length === 0) return null;
|
|
25
|
+
|
|
26
|
+
const item = items[Math.min(active, items.length - 1)];
|
|
27
|
+
const images = (item.images ?? []).filter((i) => i.src);
|
|
28
|
+
const image = images.length > 0 ? images[slide % images.length] : undefined;
|
|
29
|
+
const tone = TONE[props.tone ?? "white"] ?? "bg-white";
|
|
30
|
+
|
|
31
|
+
const select = (i: number) => {
|
|
32
|
+
setActive(i);
|
|
33
|
+
setSlide(0);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<section id={props.anchor || undefined} className={`${tone} py-16 md:py-24`}>
|
|
38
|
+
<div className="container-editorial">
|
|
39
|
+
{props.eyebrow || props.title || props.lead ? (
|
|
40
|
+
<Reveal>
|
|
41
|
+
<div className="max-w-2xl">
|
|
42
|
+
{props.eyebrow ? (
|
|
43
|
+
<span className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
|
|
44
|
+
{t(props.eyebrow)}
|
|
45
|
+
</span>
|
|
46
|
+
) : null}
|
|
47
|
+
{props.title ? (
|
|
48
|
+
<h2 className="mt-3 font-serif text-[clamp(1.8rem,3.6vw,2.9rem)] leading-[1.06] text-ink">
|
|
49
|
+
{t(props.title)}
|
|
50
|
+
</h2>
|
|
51
|
+
) : null}
|
|
52
|
+
{props.lead ? (
|
|
53
|
+
<p className="mt-4 text-base leading-relaxed text-ink/70 whitespace-pre-line">
|
|
54
|
+
{t(props.lead)}
|
|
55
|
+
</p>
|
|
56
|
+
) : null}
|
|
57
|
+
</div>
|
|
58
|
+
</Reveal>
|
|
59
|
+
) : null}
|
|
60
|
+
|
|
61
|
+
<Reveal>
|
|
62
|
+
<div className="mt-10 grid gap-8 lg:grid-cols-[minmax(0,15rem)_minmax(0,1fr)_minmax(0,1.05fr)] lg:gap-10">
|
|
63
|
+
{/* Tabs */}
|
|
64
|
+
<div
|
|
65
|
+
role="tablist"
|
|
66
|
+
aria-label={t(props.title) || "Categories"}
|
|
67
|
+
className="flex gap-2 overflow-x-auto pb-1 lg:flex-col lg:overflow-visible lg:pb-0"
|
|
68
|
+
>
|
|
69
|
+
{items.map((it, i) => {
|
|
70
|
+
const on = i === Math.min(active, items.length - 1);
|
|
71
|
+
return (
|
|
72
|
+
<button
|
|
73
|
+
key={it.id ?? i}
|
|
74
|
+
role="tab"
|
|
75
|
+
aria-selected={on}
|
|
76
|
+
onClick={() => select(i)}
|
|
77
|
+
className={`shrink-0 border px-4 py-3 text-left text-[0.7rem] uppercase tracking-[0.2em] transition-colors lg:shrink ${
|
|
78
|
+
on
|
|
79
|
+
? "border-sea bg-sea text-white"
|
|
80
|
+
: "border-ink/12 bg-white text-ink/70 hover:border-sea hover:text-ink"
|
|
81
|
+
}`}
|
|
82
|
+
>
|
|
83
|
+
{t(it.label)}
|
|
84
|
+
</button>
|
|
85
|
+
);
|
|
86
|
+
})}
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
{/* Copy */}
|
|
90
|
+
<div className="min-w-0">
|
|
91
|
+
<h3 className="font-serif text-2xl md:text-3xl text-sea">
|
|
92
|
+
{t(item.title) || t(item.label)}
|
|
93
|
+
</h3>
|
|
94
|
+
{item.body ? (
|
|
95
|
+
<p className="mt-4 text-base leading-relaxed text-ink/75 whitespace-pre-line">
|
|
96
|
+
{t(item.body)}
|
|
97
|
+
</p>
|
|
98
|
+
) : null}
|
|
99
|
+
{(item.bullets ?? []).length > 0 ? (
|
|
100
|
+
<ul className="mt-6 space-y-2.5">
|
|
101
|
+
{(item.bullets ?? []).map((b, i) => (
|
|
102
|
+
<li key={b.id ?? i} className="flex items-start gap-3">
|
|
103
|
+
<Check className="mt-1 size-4 shrink-0 text-sea" aria-hidden />
|
|
104
|
+
<span className="leading-relaxed text-ink/85">{t(b.text)}</span>
|
|
105
|
+
</li>
|
|
106
|
+
))}
|
|
107
|
+
</ul>
|
|
108
|
+
) : null}
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
{/* Gallery */}
|
|
112
|
+
<div className="min-w-0">
|
|
113
|
+
<div className="relative aspect-[4/3] w-full overflow-hidden bg-ink/10">
|
|
114
|
+
{image?.src ? (
|
|
115
|
+
<FocalImage
|
|
116
|
+
key={`${item.id}-${slide}`}
|
|
117
|
+
src={image.src}
|
|
118
|
+
alt={t(image.alt) || t(item.label) || ""}
|
|
119
|
+
loading="lazy"
|
|
120
|
+
sizes="(max-width: 1024px) 92vw, 40vw"
|
|
121
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
122
|
+
/>
|
|
123
|
+
) : null}
|
|
124
|
+
{images.length > 1 ? (
|
|
125
|
+
<>
|
|
126
|
+
<button
|
|
127
|
+
onClick={() => setSlide((s) => (s - 1 + images.length) % images.length)}
|
|
128
|
+
aria-label="Föregående bild"
|
|
129
|
+
className="absolute left-0 top-1/2 -translate-y-1/2 bg-ink/45 p-3 text-white hover:bg-ink/70"
|
|
130
|
+
>
|
|
131
|
+
<ChevronLeft className="size-5" />
|
|
132
|
+
</button>
|
|
133
|
+
<button
|
|
134
|
+
onClick={() => setSlide((s) => (s + 1) % images.length)}
|
|
135
|
+
aria-label="Nästa bild"
|
|
136
|
+
className="absolute right-0 top-1/2 -translate-y-1/2 bg-ink/45 p-3 text-white hover:bg-ink/70"
|
|
137
|
+
>
|
|
138
|
+
<ChevronRight className="size-5" />
|
|
139
|
+
</button>
|
|
140
|
+
<div className="absolute bottom-3 left-1/2 flex -translate-x-1/2 gap-2">
|
|
141
|
+
{images.map((im, i) => (
|
|
142
|
+
<button
|
|
143
|
+
key={im.id ?? i}
|
|
144
|
+
onClick={() => setSlide(i)}
|
|
145
|
+
aria-label={`Bild ${i + 1}`}
|
|
146
|
+
className={`size-2 rounded-full ${
|
|
147
|
+
i === slide % images.length ? "bg-white" : "bg-white/50"
|
|
148
|
+
}`}
|
|
149
|
+
/>
|
|
150
|
+
))}
|
|
151
|
+
</div>
|
|
152
|
+
</>
|
|
153
|
+
) : null}
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
</Reveal>
|
|
158
|
+
|
|
159
|
+
{props.note ? (
|
|
160
|
+
<p className="mt-8 max-w-3xl text-sm leading-relaxed text-ink/65 whitespace-pre-line">
|
|
161
|
+
{t(props.note)}
|
|
162
|
+
</p>
|
|
163
|
+
) : null}
|
|
164
|
+
</div>
|
|
165
|
+
</section>
|
|
166
|
+
);
|
|
167
|
+
}
|