@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.
Files changed (36) hide show
  1. package/README.md +36 -0
  2. package/package.json +39 -0
  3. package/src/backoffice/inspectors/blocks.tsx +2318 -0
  4. package/src/backoffice/inspectors/fields.tsx +436 -0
  5. package/src/blocks/block-props-context.tsx +36 -0
  6. package/src/blocks/components/CabinTabsBlock.tsx +167 -0
  7. package/src/blocks/components/ChecklistBlock.tsx +68 -0
  8. package/src/blocks/components/CtaBannerBlock.tsx +65 -0
  9. package/src/blocks/components/FaqBlock.tsx +62 -0
  10. package/src/blocks/components/HeroFullBlock.tsx +201 -0
  11. package/src/blocks/components/IconGridBlock.tsx +125 -0
  12. package/src/blocks/components/ImageMarqueeBlock.tsx +209 -0
  13. package/src/blocks/components/InstagramBlock.tsx +160 -0
  14. package/src/blocks/components/LinkTilesBlock.tsx +182 -0
  15. package/src/blocks/components/MediaSplitBlock.tsx +427 -0
  16. package/src/blocks/components/PriceTableBlock.tsx +166 -0
  17. package/src/blocks/components/ProseBlock.tsx +77 -0
  18. package/src/blocks/components/RichTextBlock.tsx +79 -0
  19. package/src/blocks/icons.ts +63 -0
  20. package/src/blocks/index.ts +13 -0
  21. package/src/blocks/registry.tsx +322 -0
  22. package/src/blocks/types.ts +470 -0
  23. package/src/host/index.tsx +134 -0
  24. package/src/html/sanitize-html.ts +175 -0
  25. package/src/i18n/context.tsx +44 -0
  26. package/src/i18n/index.ts +6 -0
  27. package/src/i18n/locales.ts +24 -0
  28. package/src/i18n/resolve.ts +82 -0
  29. package/src/i18n/translations-registry.ts +26 -0
  30. package/src/i18n/types.ts +22 -0
  31. package/src/i18n/video.ts +36 -0
  32. package/src/index.ts +5 -0
  33. package/src/theme.css +53 -0
  34. package/src/ui/Lightbox.tsx +69 -0
  35. package/src/ui/Reveal.tsx +47 -0
  36. package/src/util/scroll-lock.ts +32 -0
@@ -0,0 +1,470 @@
1
+ import type { LocalizedString } from "../i18n";
2
+
3
+ /**
4
+ * JSON-compatible value. Declared here so the package never depends on a
5
+ * site's generated database types.
6
+ */
7
+ export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[];
8
+
9
+ export type BlockProps = { [key: string]: Json | undefined };
10
+
11
+ /**
12
+ * A single block placed on a page or inside a global block.
13
+ * `props` is block-type-specific JSON; unknown/legacy blocks are ignored.
14
+ */
15
+ export type BlockInstance = {
16
+ id: string;
17
+ type: string;
18
+ /** Editor-only label so the outline/library shows what this block actually is. */
19
+ name?: string;
20
+ props?: BlockProps;
21
+ visible?: boolean;
22
+ };
23
+
24
+ export type InstagramItem = {
25
+ id: string;
26
+ image?: string;
27
+ alt?: Partial<LocalizedString>;
28
+ href?: string;
29
+ };
30
+
31
+ export type InstagramProps = {
32
+ title?: Partial<LocalizedString>;
33
+ handle?: string;
34
+ profileHref?: string;
35
+ layout?: "grid" | "carousel";
36
+ columnsMobile?: number;
37
+ columnsTablet?: number;
38
+ columnsDesktop?: number;
39
+ rows?: number;
40
+ gap?: "none" | "sm" | "md" | "lg";
41
+ aspect?: "square" | "portrait" | "landscape";
42
+ showHeader?: boolean;
43
+ showOverlay?: boolean;
44
+ items?: InstagramItem[];
45
+ };
46
+
47
+ export type FaqItem = {
48
+ id: string;
49
+ q: LocalizedString;
50
+ a: LocalizedString;
51
+ };
52
+
53
+ export type FaqProps = {
54
+ title?: LocalizedString;
55
+ /** Desktop column count for the accordion list. Mobile is always 1. */
56
+ columns?: 1 | 2 | 3;
57
+ items: FaqItem[];
58
+ };
59
+
60
+ /** ---- Icon cards ---- */
61
+
62
+ export type IconGridItem = {
63
+ id: string;
64
+ /** Key into BLOCK_ICONS (src/lib/blocks/icons.ts). */
65
+ icon?: string;
66
+ title?: Partial<LocalizedString>;
67
+ /** Small line under the title, e.g. a price. */
68
+ meta?: Partial<LocalizedString>;
69
+ body?: Partial<LocalizedString>;
70
+ href?: string;
71
+ };
72
+
73
+ export type IconGridProps = {
74
+ eyebrow?: Partial<LocalizedString>;
75
+ title?: Partial<LocalizedString>;
76
+ lead?: Partial<LocalizedString>;
77
+ note?: Partial<LocalizedString>;
78
+ tone?: "white" | "paper" | "ice";
79
+ columns?: 2 | 3 | 4 | 5;
80
+ iconStyle?: "circle" | "plain";
81
+ /** Horizontal placement of the icon above the card text. Defaults to centred. */
82
+ iconAlign?: "left" | "center" | "right";
83
+ cardStyle?: "bordered" | "bare";
84
+ anchor?: string;
85
+ items?: IconGridItem[];
86
+ };
87
+
88
+ /** ---- Image marquee (two columns drifting in opposite directions) ---- */
89
+
90
+ export type ImageMarqueeItem = {
91
+ id: string;
92
+ image?: string;
93
+ alt?: Partial<LocalizedString>;
94
+ /** 1-based column this image belongs to. Empty = spread across all columns. */
95
+ column?: number;
96
+ };
97
+
98
+ export type ImageMarqueeProps = {
99
+ eyebrow?: Partial<LocalizedString>;
100
+ title?: Partial<LocalizedString>;
101
+ lead?: Partial<LocalizedString>;
102
+ /** Heading/body passages beside the marquee (same shape as the Prose block). */
103
+ items2?: ProseItem[];
104
+ textPosition?: "left" | "right" | "none";
105
+ columns?: 1 | 2 | 3;
106
+ firstDirection?: "up" | "down";
107
+ speed?: "ultra-slow" | "very-slow" | "slow" | "normal" | "fast";
108
+ aspect?: "portrait" | "square" | "landscape" | "mixed";
109
+ gap?: "sm" | "md" | "lg";
110
+ rounded?: boolean;
111
+ tone?: "white" | "paper" | "ice";
112
+ anchor?: string;
113
+ items?: ImageMarqueeItem[];
114
+ };
115
+
116
+
117
+ export type GlobalRefProps = {
118
+ sectionKey: string;
119
+ };
120
+
121
+ /** ---- Inline-editable block props (Phase 2 backoffice v2) ---- */
122
+
123
+ export type CtaProps = {
124
+ label?: Partial<LocalizedString>;
125
+ href?: string;
126
+ };
127
+
128
+ export type HeroProps = {
129
+ eyebrow?: Partial<LocalizedString>;
130
+ title?: Partial<LocalizedString>;
131
+ body?: Partial<LocalizedString>;
132
+ primaryCta?: CtaProps;
133
+ secondaryCta?: CtaProps;
134
+ /** Desktop (default) background video URL. */
135
+ videoSrc?: string;
136
+ /** Desktop (default) poster/still image URL. */
137
+ posterSrc?: string;
138
+ /** Tablet-viewport overrides (~≤1024px). Falls back to desktop when empty. */
139
+ videoSrcTablet?: string;
140
+ posterSrcTablet?: string;
141
+ /** Mobile-viewport overrides (~≤640px). Falls back to tablet, then desktop. */
142
+ videoSrcMobile?: string;
143
+ posterSrcMobile?: string;
144
+ };
145
+
146
+ /** ---- Hero Section Fullscreen (image or video background) ---- */
147
+
148
+ export type HeroFullProps = {
149
+ eyebrow?: Partial<LocalizedString>;
150
+ title?: Partial<LocalizedString>;
151
+ body?: Partial<LocalizedString>;
152
+ primaryCta?: CtaProps;
153
+ secondaryCta?: CtaProps;
154
+
155
+ /** Background media */
156
+ mediaType?: "image" | "video";
157
+ image?: string;
158
+ imageMobile?: string;
159
+ imageAlt?: Partial<LocalizedString>;
160
+ videoSrc?: string;
161
+ videoSrcMobile?: string;
162
+ /** Optional WebM variant, offered to browsers that support it. */
163
+ videoSrcWebm?: string;
164
+ videoSrcWebmMobile?: string;
165
+ posterSrc?: string;
166
+ posterSrcMobile?: string;
167
+ videoAutoplay?: boolean;
168
+ videoLoop?: boolean;
169
+ videoMuted?: boolean;
170
+
171
+ /** Layout & styling */
172
+ height?: "full" | "tall" | "medium" | "compact";
173
+ overlay?: "none" | "light" | "medium" | "strong" | "bottom" | "top" | "side";
174
+ textAlign?: "left" | "center" | "right";
175
+ verticalAlign?: "top" | "center" | "bottom";
176
+ textTone?: "light" | "dark";
177
+ contentWidth?: "narrow" | "normal" | "wide" | "full";
178
+ titleSize?: "sm" | "md" | "lg" | "xl";
179
+ titleFont?: "serif" | "sans";
180
+ titleUppercase?: boolean;
181
+ showScrollCue?: boolean;
182
+ anchor?: string;
183
+ };
184
+
185
+
186
+ export type IntroProps = {
187
+ eyebrow?: Partial<LocalizedString>;
188
+ title?: Partial<LocalizedString>;
189
+ body?: Partial<LocalizedString>;
190
+ /** Per-block image override; empty falls back to shared site content. */
191
+ imageSrc?: string;
192
+ imageAlt?: Partial<LocalizedString>;
193
+ /** Image shape (width:height). "auto" keeps the file's own ratio. */
194
+ imageAspect?: IntroImageAspect;
195
+ /** Image width as a percentage of its column (30–100). Default 100. */
196
+ imageWidth?: number;
197
+ };
198
+
199
+ export const INTRO_IMAGE_ASPECTS = [
200
+ "4/5",
201
+ "1/1",
202
+ "3/4",
203
+ "3/2",
204
+ "16/9",
205
+ "auto",
206
+ ] as const;
207
+
208
+ export type IntroImageAspect = (typeof INTRO_IMAGE_ASPECTS)[number];
209
+
210
+
211
+ /** ---- Long-form editorial blocks ---- */
212
+
213
+ export type ProseItem = {
214
+ id: string;
215
+ heading?: Partial<LocalizedString>;
216
+ body?: Partial<LocalizedString>;
217
+ };
218
+
219
+ export type ProseProps = {
220
+ eyebrow?: Partial<LocalizedString>;
221
+ title?: Partial<LocalizedString>;
222
+ lead?: Partial<LocalizedString>;
223
+ layout?: "single" | "two-col";
224
+ tone?: "white" | "paper";
225
+ items?: ProseItem[];
226
+ };
227
+
228
+ export type ChecklistEntry = {
229
+ id: string;
230
+ text: Partial<LocalizedString>;
231
+ };
232
+
233
+ export type ChecklistColumn = {
234
+ id: string;
235
+ title?: Partial<LocalizedString>;
236
+ entries?: ChecklistEntry[];
237
+ };
238
+
239
+ export type ChecklistProps = {
240
+ eyebrow?: Partial<LocalizedString>;
241
+ title?: Partial<LocalizedString>;
242
+ note?: Partial<LocalizedString>;
243
+ tone?: "white" | "paper";
244
+ columns?: ChecklistColumn[];
245
+ };
246
+
247
+ /** ---- Bookable course dates (inline, per page instance) ---- */
248
+
249
+ export type CourseListItem = {
250
+ id: string;
251
+ /** Season/year used to group and filter courses (e.g. "2026"). */
252
+ year?: string;
253
+ courseNo?: Partial<LocalizedString>;
254
+ location?: Partial<LocalizedString>;
255
+ dates?: Partial<LocalizedString>;
256
+ accommodation?: Partial<LocalizedString>;
257
+ price?: Partial<LocalizedString>;
258
+ oldPrice?: Partial<LocalizedString>;
259
+ badge?: Partial<LocalizedString>;
260
+ bookLabel?: Partial<LocalizedString>;
261
+ bookUrl?: string;
262
+ featured?: boolean;
263
+ };
264
+
265
+ export type CourseListProps = {
266
+ eyebrow?: Partial<LocalizedString>;
267
+ title?: Partial<LocalizedString>;
268
+ lead?: Partial<LocalizedString>;
269
+ note?: Partial<LocalizedString>;
270
+ tone?: "white" | "paper";
271
+ columns?: 2 | 3 | 4;
272
+ anchor?: string;
273
+ /**
274
+ * Where the courses come from.
275
+ * "global" (default) = the single source of truth in the backoffice
276
+ * (Courses section), optionally filtered by `year`.
277
+ * "custom" = items edited inline on this block only (legacy).
278
+ */
279
+ source?: "global" | "custom";
280
+ /** Only used with source="global": show courses of this year. */
281
+ year?: string;
282
+ items?: CourseListItem[];
283
+ };
284
+
285
+ /** ---- Simple call-to-action banner ---- */
286
+
287
+ export type CtaBannerProps = {
288
+ eyebrow?: Partial<LocalizedString>;
289
+ title?: Partial<LocalizedString>;
290
+ body?: Partial<LocalizedString>;
291
+ tone?: "ink" | "sea" | "paper";
292
+ cta?: CtaProps;
293
+ secondaryCta?: CtaProps;
294
+ };
295
+
296
+ /** ---- Image or video — Left/Right split section ---- */
297
+
298
+ export type MediaSplitSlide = {
299
+ id: string;
300
+ image?: string;
301
+ alt?: Partial<LocalizedString>;
302
+ caption?: Partial<LocalizedString>;
303
+ href?: string;
304
+ };
305
+
306
+ export type MediaSplitBullet = {
307
+ id: string;
308
+ text: Partial<LocalizedString>;
309
+ };
310
+
311
+ export type MediaSplitProps = {
312
+ /** Preset that seeds a coherent group of layout defaults. */
313
+ style?: "editorial" | "compact" | "fullBleed" | "dark";
314
+
315
+ eyebrow?: Partial<LocalizedString>;
316
+ title?: Partial<LocalizedString>;
317
+ lead?: Partial<LocalizedString>;
318
+ body?: Partial<LocalizedString>;
319
+ bullets?: MediaSplitBullet[];
320
+ cta?: CtaProps;
321
+ secondaryCta?: CtaProps;
322
+
323
+ /** Layout */
324
+ mediaSide?: "left" | "right";
325
+ mediaRatio?: "40" | "50" | "60";
326
+ align?: "top" | "center";
327
+ padding?: "compact" | "normal" | "spacious";
328
+ tone?: "white" | "paper" | "ink" | "sea";
329
+ fullBleed?: boolean;
330
+ rounded?: boolean;
331
+ aspect?: "square" | "portrait" | "landscape" | "wide" | "auto";
332
+ anchor?: string;
333
+
334
+ /** Media */
335
+ mediaType?: "image" | "slideshow" | "video";
336
+ /** Backdrop behind the media. "transparent" blends with the section. */
337
+ mediaBg?: "tint" | "transparent";
338
+ /** How the media fills its frame. "contain" suits logos. */
339
+ mediaFit?: "cover" | "contain";
340
+ image?: string;
341
+ imageAlt?: Partial<LocalizedString>;
342
+ slides?: MediaSplitSlide[];
343
+
344
+ /** Slideshow options */
345
+ autoplay?: boolean;
346
+ intervalMs?: number;
347
+ showDots?: boolean;
348
+ showArrows?: boolean;
349
+ loop?: boolean;
350
+ showCaptions?: boolean;
351
+
352
+ /** Video options */
353
+ videoSrc?: string;
354
+ posterSrc?: string;
355
+ videoAutoplay?: boolean;
356
+ videoLoop?: boolean;
357
+ videoMuted?: boolean;
358
+ videoControls?: boolean;
359
+ };
360
+
361
+
362
+ /** ---- Rich text (WYSIWYG) ---- */
363
+
364
+ export type RichTextProps = {
365
+ eyebrow?: Partial<LocalizedString>;
366
+ title?: Partial<LocalizedString>;
367
+ /** Sanitized HTML per locale, produced by the backoffice editor. */
368
+ html?: Partial<LocalizedString>;
369
+ tone?: "white" | "paper" | "ice" | "ink";
370
+ width?: "narrow" | "normal" | "wide" | "full";
371
+ align?: "left" | "center";
372
+ padding?: "none" | "compact" | "normal" | "spacious";
373
+ columns?: 1 | 2;
374
+ dropCap?: boolean;
375
+ anchor?: string;
376
+ };
377
+
378
+ /** ---- Link tiles (row of linked image cards with overlay label) ---- */
379
+
380
+ export type LinkTilesItem = {
381
+ id: string;
382
+ image?: string;
383
+ alt?: Partial<LocalizedString>;
384
+ eyebrow?: Partial<LocalizedString>;
385
+ label?: Partial<LocalizedString>;
386
+ body?: Partial<LocalizedString>;
387
+ /** Internal path (/boka) or absolute URL. */
388
+ href?: string;
389
+ };
390
+
391
+ export type LinkTilesProps = {
392
+ eyebrow?: Partial<LocalizedString>;
393
+ title?: Partial<LocalizedString>;
394
+ lead?: Partial<LocalizedString>;
395
+ tone?: "white" | "paper" | "ice" | "ink";
396
+ columns?: 1 | 2 | 3 | 4;
397
+ gap?: "sm" | "md" | "lg";
398
+ aspect?: "portrait" | "square" | "landscape" | "tall";
399
+ overlay?: "none" | "light" | "medium" | "strong" | "brand";
400
+ labelAlign?: "left" | "center";
401
+ rounded?: boolean;
402
+ anchor?: string;
403
+ items?: LinkTilesItem[];
404
+ };
405
+
406
+ /** ---- Cabin tabs (clickable cabin categories with their own gallery) ---- */
407
+
408
+ export type CabinImage = {
409
+ id: string;
410
+ src?: string;
411
+ alt?: Partial<LocalizedString>;
412
+ };
413
+
414
+ export type CabinTabItem = {
415
+ id: string;
416
+ /** Short tab label, e.g. "E-HYTT". */
417
+ label?: Partial<LocalizedString>;
418
+ /** Heading shown above the description (defaults to the label). */
419
+ title?: Partial<LocalizedString>;
420
+ body?: Partial<LocalizedString>;
421
+ bullets?: { id: string; text?: Partial<LocalizedString> }[];
422
+ images?: CabinImage[];
423
+ };
424
+
425
+ export type CabinTabsProps = {
426
+ eyebrow?: Partial<LocalizedString>;
427
+ title?: Partial<LocalizedString>;
428
+ lead?: Partial<LocalizedString>;
429
+ note?: Partial<LocalizedString>;
430
+ tone?: "white" | "paper" | "ice";
431
+ anchor?: string;
432
+ items?: CabinTabItem[];
433
+ };
434
+
435
+ /** ---- Price table (cabin prices per person) ---- */
436
+
437
+ export type PriceRow = {
438
+ id: string;
439
+ /** Left column, e.g. "4 pers". */
440
+ label?: Partial<LocalizedString>;
441
+ /** Right column, e.g. "1 395 kr". */
442
+ price?: Partial<LocalizedString>;
443
+ /** Hide this row on the site without deleting it. */
444
+ hidden?: boolean;
445
+ };
446
+
447
+ export type PriceGroup = {
448
+ id: string;
449
+ label?: Partial<LocalizedString>;
450
+ caption?: Partial<LocalizedString>;
451
+ highlight?: boolean;
452
+ rows?: PriceRow[];
453
+ };
454
+
455
+ export type PriceTableProps = {
456
+ eyebrow?: Partial<LocalizedString>;
457
+ title?: Partial<LocalizedString>;
458
+ lead?: Partial<LocalizedString>;
459
+ unitLabel?: Partial<LocalizedString>;
460
+ note?: Partial<LocalizedString>;
461
+ ctaLabel?: Partial<LocalizedString>;
462
+ ctaHref?: string;
463
+ tone?: "white" | "paper" | "ice" | "ink";
464
+ columns?: 2 | 3;
465
+ /** Horizontal alignment of heading + note/CTA. Defaults to centered. */
466
+ align?: "left" | "center";
467
+ anchor?: string;
468
+
469
+ groups?: PriceGroup[];
470
+ };
@@ -0,0 +1,134 @@
1
+ import type { ComponentType, ImgHTMLAttributes } from "react";
2
+ import type { UseQueryOptions } from "@tanstack/react-query";
3
+
4
+ /**
5
+ * Host component contract.
6
+ *
7
+ * A few primitives shared blocks and inspectors render are backed by per-site
8
+ * infrastructure (media library, storage uploads, focus points). The package
9
+ * declares the props/shapes it will use; each site registers its own
10
+ * implementation once at startup. That keeps media plumbing, storage and
11
+ * server functions per-site while block markup and inspector UI stay shared.
12
+ */
13
+
14
+ export type HostFocalImageProps = Omit<ImgHTMLAttributes<HTMLImageElement>, "src"> & {
15
+ src: string | undefined;
16
+ /** When set, the host may build a responsive srcSet. */
17
+ sizes?: string;
18
+ responsive?: boolean;
19
+ };
20
+
21
+ export type HostLazyVideoProps = {
22
+ src?: string;
23
+ /** Optional alternative sources (e.g. WebM) tried before `src`. */
24
+ sources?: Array<{ src: string; type: string }>;
25
+ poster?: string;
26
+ className?: string;
27
+ autoPlay?: boolean;
28
+ loop?: boolean;
29
+ muted?: boolean;
30
+ controls?: boolean;
31
+ preload?: "none" | "metadata" | "auto";
32
+ ariaLabel?: string;
33
+ playLabel?: { sv: string } & Record<string, string>;
34
+ };
35
+
36
+ /** One file in a site's media library. */
37
+ export type HostMediaItem = {
38
+ name: string;
39
+ path: string;
40
+ size: number;
41
+ contentType: string | null;
42
+ updatedAt: string | null;
43
+ url: string;
44
+ uploadedBy?: string | null;
45
+ uploadedAt?: string | null;
46
+ };
47
+
48
+ export type HostFocusPickerProps = {
49
+ src: string;
50
+ onClose?: () => void;
51
+ modal?: boolean;
52
+ title?: string;
53
+ };
54
+
55
+ export type HostRichTextEditorProps = {
56
+ value: string;
57
+ onChange: (html: string) => void;
58
+ placeholder?: string;
59
+ };
60
+
61
+ export type HostMediaUpload = {
62
+ upload: (files: FileList | File[] | null) => Promise<string[]>;
63
+ uploading: boolean;
64
+ progress: { label: string; percent: number } | null;
65
+ };
66
+
67
+ /** Structural shape of the site's `queryOptions(...)` for its media library. */
68
+ export type HostMediaLibraryOptions = UseQueryOptions<
69
+ HostMediaItem[],
70
+ Error,
71
+ HostMediaItem[],
72
+ readonly unknown[]
73
+ >;
74
+
75
+ export type HostComponents = {
76
+ FocalImage: ComponentType<HostFocalImageProps>;
77
+ LazyVideo: ComponentType<HostLazyVideoProps>;
78
+ FocusPicker: ComponentType<HostFocusPickerProps>;
79
+ RichTextEditor: ComponentType<HostRichTextEditorProps>;
80
+ /** react-query options that list the site's media library. */
81
+ mediaLibraryQueryOptions: HostMediaLibraryOptions;
82
+ /** Hook wrapping the site's upload pipeline. */
83
+ useMediaUpload: (existing?: HostMediaItem[]) => HostMediaUpload;
84
+ };
85
+
86
+ let hosts: Partial<HostComponents> = {};
87
+
88
+ export function registerHostComponents(components: Partial<HostComponents>): void {
89
+ hosts = { ...hosts, ...components };
90
+ }
91
+
92
+ function missing(name: keyof HostComponents): never {
93
+ throw new Error(
94
+ `@bygghammar/cms-platform: host "${name}" is not registered. Call registerHostComponents() during site startup.`,
95
+ );
96
+ }
97
+
98
+ /** Focus-point aware image. Renders nothing until the site registers a host. */
99
+ export function FocalImage(props: HostFocalImageProps) {
100
+ const Impl = hosts.FocalImage;
101
+ if (!Impl) return null;
102
+ return <Impl {...props} />;
103
+ }
104
+
105
+ /** Bandwidth-safe video. Renders nothing until the site registers a host. */
106
+ export function LazyVideo(props: HostLazyVideoProps) {
107
+ const Impl = hosts.LazyVideo;
108
+ if (!Impl) return null;
109
+ return <Impl {...props} />;
110
+ }
111
+
112
+ /** Backoffice focus-point picker, provided by the site. */
113
+ export function FocusPicker(props: HostFocusPickerProps) {
114
+ const Impl = hosts.FocusPicker;
115
+ if (!Impl) return null;
116
+ return <Impl {...props} />;
117
+ }
118
+
119
+ /** Backoffice WYSIWYG editor, provided by the site. */
120
+ export function RichTextEditor(props: HostRichTextEditorProps) {
121
+ const Impl = hosts.RichTextEditor;
122
+ if (!Impl) return null;
123
+ return <Impl {...props} />;
124
+ }
125
+
126
+ export function useHostMediaLibraryOptions(): HostMediaLibraryOptions {
127
+ return hosts.mediaLibraryQueryOptions ?? missing("mediaLibraryQueryOptions");
128
+ }
129
+
130
+ export function useHostMediaUpload(existing: HostMediaItem[] = []): HostMediaUpload {
131
+ const hook = hosts.useMediaUpload;
132
+ if (!hook) missing("useMediaUpload");
133
+ return hook(existing);
134
+ }