@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,79 @@
1
+ import { useT } from "../../i18n";
2
+ import type { LocalizedString } from "../../i18n";
3
+ import { Reveal } from "../../ui/Reveal";
4
+ import type { RichTextProps } from "../types";
5
+ import { sanitizeHtml } from "../../html/sanitize-html";
6
+
7
+ const TONES: Record<string, string> = {
8
+ white: "bg-white text-ink",
9
+ paper: "bg-paper text-ink",
10
+ ice: "bg-ice text-ink",
11
+ ink: "bg-ink text-paper",
12
+ };
13
+
14
+ const WIDTHS: Record<string, string> = {
15
+ narrow: "max-w-2xl",
16
+ normal: "max-w-3xl",
17
+ wide: "max-w-5xl",
18
+ full: "max-w-none",
19
+ };
20
+
21
+ const PADDING: Record<string, string> = {
22
+ none: "py-0",
23
+ compact: "py-10 md:py-14",
24
+ normal: "py-20 md:py-28",
25
+ spacious: "py-28 md:py-40",
26
+ };
27
+
28
+ /**
29
+ * Free-form rich text authored in the backoffice WYSIWYG editor.
30
+ * The stored HTML is sanitized on every render (never trusted as-is).
31
+ */
32
+ export function RichTextBlock({ props }: { props: RichTextProps }) {
33
+ const { t } = useT();
34
+ const tone = props.tone ?? "white";
35
+ const html = sanitizeHtml(t(props.html as LocalizedString));
36
+
37
+ return (
38
+ <section
39
+ id={props.anchor || undefined}
40
+ className={`${TONES[tone] ?? TONES.white} ${PADDING[props.padding ?? "normal"]}`}
41
+ >
42
+ <div className="container-editorial">
43
+ <div
44
+ className={[
45
+ WIDTHS[props.width ?? "normal"],
46
+ props.align === "center" ? "mx-auto text-center" : "",
47
+ ].join(" ")}
48
+ >
49
+ {props.eyebrow || props.title ? (
50
+ <Reveal>
51
+ {props.eyebrow ? (
52
+ <span className="text-[0.7rem] uppercase tracking-[0.22em] opacity-70">
53
+ {t(props.eyebrow as LocalizedString)}
54
+ </span>
55
+ ) : null}
56
+ {props.title ? (
57
+ <h2 className="mt-4 mb-8 font-serif text-[clamp(1.9rem,4vw,3.25rem)] leading-[1.05] tracking-[-0.02em] whitespace-pre-line">
58
+ {t(props.title as LocalizedString)}
59
+ </h2>
60
+ ) : null}
61
+ </Reveal>
62
+ ) : null}
63
+
64
+ <Reveal>
65
+ <div
66
+ className={[
67
+ "rte-content",
68
+ tone === "ink" ? "rte-invert" : "",
69
+ props.columns === 2 ? "md:columns-2 md:gap-12" : "",
70
+ props.dropCap ? "rte-dropcap" : "",
71
+ ].join(" ")}
72
+ dangerouslySetInnerHTML={{ __html: html }}
73
+ />
74
+ </Reveal>
75
+ </div>
76
+ </div>
77
+ </section>
78
+ );
79
+ }
@@ -0,0 +1,63 @@
1
+ import {
2
+ Award,
3
+ BadgeCheck,
4
+ Bed,
5
+ Briefcase,
6
+ CalendarClock,
7
+ CalendarCheck,
8
+ CreditCard,
9
+ Flame,
10
+ GlassWater,
11
+ GraduationCap,
12
+ Handshake,
13
+ Heart,
14
+ Martini,
15
+ PartyPopper,
16
+ Plane,
17
+ ShieldCheck,
18
+ Sparkles,
19
+ Star,
20
+ Sun,
21
+ Ticket,
22
+ Users,
23
+ Wallet,
24
+ BookOpen,
25
+ Palmtree,
26
+ type LucideIcon,
27
+ } from "lucide-react";
28
+
29
+ /** Curated icon set available to the Icon cards block (backoffice + frontend). */
30
+ export const BLOCK_ICONS: Record<string, LucideIcon> = {
31
+ award: Award,
32
+ badgeCheck: BadgeCheck,
33
+ bed: Bed,
34
+ bookOpen: BookOpen,
35
+ briefcase: Briefcase,
36
+ calendarCheck: CalendarCheck,
37
+ calendarClock: CalendarClock,
38
+ creditCard: CreditCard,
39
+ flame: Flame,
40
+ glassWater: GlassWater,
41
+ graduationCap: GraduationCap,
42
+ handshake: Handshake,
43
+ heart: Heart,
44
+ martini: Martini,
45
+ palmtree: Palmtree,
46
+ partyPopper: PartyPopper,
47
+ plane: Plane,
48
+ shieldCheck: ShieldCheck,
49
+ sparkles: Sparkles,
50
+ star: Star,
51
+ sun: Sun,
52
+ ticket: Ticket,
53
+ users: Users,
54
+ wallet: Wallet,
55
+ };
56
+
57
+ export type BlockIconName = keyof typeof BLOCK_ICONS;
58
+
59
+ export const BLOCK_ICON_NAMES = Object.keys(BLOCK_ICONS);
60
+
61
+ export function resolveBlockIcon(name?: string): LucideIcon {
62
+ return (name && BLOCK_ICONS[name]) || Sparkles;
63
+ }
@@ -0,0 +1,13 @@
1
+ export { CabinTabsBlock } from "./components/CabinTabsBlock";
2
+ export { ChecklistBlock } from "./components/ChecklistBlock";
3
+ export { CtaBannerBlock } from "./components/CtaBannerBlock";
4
+ export { FaqBlock } from "./components/FaqBlock";
5
+ export { HeroFullBlock } from "./components/HeroFullBlock";
6
+ export { IconGridBlock } from "./components/IconGridBlock";
7
+ export { ImageMarqueeBlock } from "./components/ImageMarqueeBlock";
8
+ export { InstagramBlock } from "./components/InstagramBlock";
9
+ export { LinkTilesBlock } from "./components/LinkTilesBlock";
10
+ export { MediaSplitBlock } from "./components/MediaSplitBlock";
11
+ export { PriceTableBlock } from "./components/PriceTableBlock";
12
+ export { ProseBlock } from "./components/ProseBlock";
13
+ export { RichTextBlock } from "./components/RichTextBlock";
@@ -0,0 +1,322 @@
1
+ import type { ComponentType } from "react";
2
+ import { FaqBlock } from "./components/FaqBlock";
3
+ import { InstagramBlock } from "./components/InstagramBlock";
4
+ import { ProseBlock } from "./components/ProseBlock";
5
+ import { RichTextBlock } from "./components/RichTextBlock";
6
+ import { ChecklistBlock } from "./components/ChecklistBlock";
7
+ import { IconGridBlock } from "./components/IconGridBlock";
8
+ import { CtaBannerBlock } from "./components/CtaBannerBlock";
9
+ import { MediaSplitBlock } from "./components/MediaSplitBlock";
10
+ import { HeroFullBlock } from "./components/HeroFullBlock";
11
+ import { ImageMarqueeBlock } from "./components/ImageMarqueeBlock";
12
+ import { LinkTilesBlock } from "./components/LinkTilesBlock";
13
+ import { CabinTabsBlock } from "./components/CabinTabsBlock";
14
+ import { PriceTableBlock } from "./components/PriceTableBlock";
15
+ import type {
16
+ BlockProps,
17
+ FaqProps,
18
+ InstagramProps,
19
+ ProseProps,
20
+ RichTextProps,
21
+ ChecklistProps,
22
+ IconGridProps,
23
+ CtaBannerProps,
24
+ MediaSplitProps,
25
+ HeroFullProps,
26
+ ImageMarqueeProps,
27
+ LinkTilesProps,
28
+ CabinTabsProps,
29
+ PriceTableProps,
30
+ } from "./types";
31
+ import { BlockPropsProvider } from "./block-props-context";
32
+
33
+ export type BlockDef = {
34
+ type: string;
35
+ label: string;
36
+ description: string;
37
+ category: "section" | "content" | "reference";
38
+ component: ComponentType<{ props: BlockProps }>;
39
+ defaultProps: BlockProps;
40
+ /** True when the block reads editable content from its own `props`. */
41
+ hasInlineContent: boolean;
42
+ };
43
+
44
+ /**
45
+ * Wrap a props-context based section component (site-owned sections read their
46
+ * props from `BlockPropsProvider`).
47
+ */
48
+ export const wrapBlock = (C: ComponentType) =>
49
+ function Wrapped({ props }: { props: BlockProps }) {
50
+ return (
51
+ <BlockPropsProvider value={props}>
52
+ <C />
53
+ </BlockPropsProvider>
54
+ );
55
+ };
56
+
57
+ /**
58
+ * Blocks shared by every site. Site-specific blocks (course lists, forms,
59
+ * legacy sections, global refs) are merged in by each site's own registry.
60
+ * Default copy here is intentionally brand-neutral.
61
+ */
62
+ export const SHARED_BLOCK_DEFS: Record<string, BlockDef> = {
63
+ faq: {
64
+ type: "faq",
65
+ label: "FAQ",
66
+ description: "Accordion of questions & answers. Only one open at a time.",
67
+ category: "content",
68
+ component: ({ props }) => <FaqBlock props={props as FaqProps} />,
69
+ defaultProps: {
70
+ title: { sv: "Vanliga frågor", en: "Frequently asked questions" },
71
+ items: [],
72
+ } satisfies FaqProps as unknown as BlockProps,
73
+ hasInlineContent: true,
74
+ },
75
+ instagram: {
76
+ type: "instagram",
77
+ label: "Instagram",
78
+ description:
79
+ "Instagram-style image grid or carousel with configurable columns, rows and layout.",
80
+ category: "content",
81
+ component: ({ props }) => <InstagramBlock props={props as InstagramProps} />,
82
+ defaultProps: {
83
+ title: { sv: "Följ oss på Instagram", en: "Follow us on Instagram" },
84
+ handle: "",
85
+ layout: "grid",
86
+ columnsMobile: 2,
87
+ columnsTablet: 3,
88
+ columnsDesktop: 4,
89
+ rows: 2,
90
+ gap: "md",
91
+ aspect: "square",
92
+ showHeader: true,
93
+ showOverlay: true,
94
+ items: [],
95
+ } satisfies InstagramProps as unknown as BlockProps,
96
+ hasInlineContent: true,
97
+ },
98
+ prose: {
99
+ type: "prose",
100
+ label: "Text section",
101
+ description:
102
+ "Editorial long-form text: eyebrow, title, lead and heading/body passages.",
103
+ category: "content",
104
+ component: ({ props }) => <ProseBlock props={props as ProseProps} />,
105
+ defaultProps: {
106
+ title: { sv: "Rubrik", en: "Heading" },
107
+ layout: "single",
108
+ tone: "white",
109
+ items: [],
110
+ } satisfies ProseProps as unknown as BlockProps,
111
+ hasInlineContent: true,
112
+ },
113
+ richText: {
114
+ type: "richText",
115
+ label: "Rich text (WYSIWYG)",
116
+ description:
117
+ "Free-form text you write or paste in a full editor: headings H1–H6, bold, links, images, colours, fonts, lists, quotes and tables.",
118
+ category: "content",
119
+ component: ({ props }) => <RichTextBlock props={props as RichTextProps} />,
120
+ defaultProps: {
121
+ html: {
122
+ sv: "<h2>Rubrik</h2><p>Skriv eller klistra in din text här.</p>",
123
+ en: "<h2>Heading</h2><p>Write or paste your text here.</p>",
124
+ },
125
+ tone: "white",
126
+ width: "normal",
127
+ align: "left",
128
+ padding: "normal",
129
+ columns: 1,
130
+ dropCap: false,
131
+ } satisfies RichTextProps as unknown as BlockProps,
132
+ hasInlineContent: true,
133
+ },
134
+ checklist: {
135
+ type: "checklist",
136
+ label: "Checklist",
137
+ description:
138
+ "One or two columns of checkmark lists, e.g. what's included and extra perks.",
139
+ category: "content",
140
+ component: ({ props }) => <ChecklistBlock props={props as ChecklistProps} />,
141
+ defaultProps: {
142
+ title: { sv: "Vad ingår?", en: "What's included?" },
143
+ tone: "paper",
144
+ columns: [],
145
+ } satisfies ChecklistProps as unknown as BlockProps,
146
+ hasInlineContent: true,
147
+ },
148
+ iconGrid: {
149
+ type: "iconGrid",
150
+ label: "Icon cards",
151
+ description:
152
+ "Grid of icon + title cards with optional price/meta line and text. 1 column on mobile, 2–5 on desktop.",
153
+ category: "content",
154
+ component: ({ props }) => <IconGridBlock props={props as IconGridProps} />,
155
+ defaultProps: {
156
+ title: { sv: "Rubrik", en: "Heading" },
157
+ tone: "white",
158
+ columns: 3,
159
+ iconStyle: "circle",
160
+ iconAlign: "center",
161
+ cardStyle: "bordered",
162
+ items: [],
163
+ } satisfies IconGridProps as unknown as BlockProps,
164
+ hasInlineContent: true,
165
+ },
166
+ ctaBanner: {
167
+ type: "ctaBanner",
168
+ label: "CTA banner",
169
+ description: "Call-to-action banner with heading, text and up to two buttons.",
170
+ category: "content",
171
+ component: ({ props }) => <CtaBannerBlock props={props as CtaBannerProps} />,
172
+ defaultProps: {
173
+ title: { sv: "Redo att boka?", en: "Ready to book?" },
174
+ tone: "ink",
175
+ cta: { label: { sv: "Boka nu", en: "Book now" }, href: "/" },
176
+ } satisfies CtaBannerProps as unknown as BlockProps,
177
+ hasInlineContent: true,
178
+ },
179
+ heroFull: {
180
+ type: "heroFull",
181
+ label: "Hero Section Fullscreen",
182
+ description:
183
+ "Fullscreen hero with an image or video background, overlay text in any size, alignment left/centre/right and separate mobile media.",
184
+ category: "section",
185
+ component: ({ props }) => <HeroFullBlock props={props as HeroFullProps} />,
186
+ defaultProps: {
187
+ eyebrow: { sv: "", en: "" },
188
+ title: { sv: "Rubrik", en: "Heading" },
189
+ body: { sv: "", en: "" },
190
+ mediaType: "image",
191
+ height: "full",
192
+ overlay: "medium",
193
+ textAlign: "left",
194
+ verticalAlign: "center",
195
+ textTone: "light",
196
+ contentWidth: "normal",
197
+ titleSize: "lg",
198
+ titleFont: "serif",
199
+ titleUppercase: false,
200
+ showScrollCue: true,
201
+ videoAutoplay: true,
202
+ videoLoop: true,
203
+ videoMuted: true,
204
+ } satisfies HeroFullProps as unknown as BlockProps,
205
+ hasInlineContent: true,
206
+ },
207
+ mediaSplit: {
208
+ type: "mediaSplit",
209
+ label: "Image or video — Left/Right",
210
+ description:
211
+ "Split section with image, slideshow or video on one side and editorial text on the other. Mirrorable.",
212
+ category: "content",
213
+ component: ({ props }) => <MediaSplitBlock props={props as MediaSplitProps} />,
214
+ defaultProps: {
215
+ style: "editorial",
216
+ eyebrow: { sv: "", en: "" },
217
+ title: { sv: "Rubrik", en: "Heading" },
218
+ lead: { sv: "", en: "" },
219
+ body: { sv: "", en: "" },
220
+ mediaSide: "left",
221
+ mediaType: "image",
222
+ mediaRatio: "50",
223
+ align: "center",
224
+ padding: "normal",
225
+ tone: "white",
226
+ aspect: "landscape",
227
+ autoplay: true,
228
+ intervalMs: 5000,
229
+ showDots: true,
230
+ showArrows: true,
231
+ loop: true,
232
+ videoAutoplay: true,
233
+ videoLoop: true,
234
+ videoMuted: true,
235
+ slides: [],
236
+ bullets: [],
237
+ } satisfies MediaSplitProps as unknown as BlockProps,
238
+ hasInlineContent: true,
239
+ },
240
+ imageMarquee: {
241
+ type: "imageMarquee",
242
+ label: "Image marquee",
243
+ description:
244
+ "Columns of photos drifting in opposite directions, with an optional text column beside them.",
245
+ category: "content",
246
+ component: ({ props }) => <ImageMarqueeBlock props={props as ImageMarqueeProps} />,
247
+ defaultProps: {
248
+ eyebrow: { sv: "" },
249
+ title: { sv: "Rubrik" },
250
+ lead: { sv: "" },
251
+ textPosition: "left",
252
+ columns: 2,
253
+ firstDirection: "up",
254
+ speed: "normal",
255
+ aspect: "portrait",
256
+ gap: "md",
257
+ rounded: false,
258
+ tone: "white",
259
+ items: [],
260
+ items2: [],
261
+ } satisfies ImageMarqueeProps as unknown as BlockProps,
262
+ hasInlineContent: true,
263
+ },
264
+ linkTiles: {
265
+ type: "linkTiles",
266
+ label: "Link tiles",
267
+ description:
268
+ "Row of clickable image tiles with an overlay label — image, text and link are editable per tile.",
269
+ category: "content",
270
+ component: ({ props }) => <LinkTilesBlock props={props as LinkTilesProps} />,
271
+ defaultProps: {
272
+ tone: "white",
273
+ columns: 3,
274
+ gap: "md",
275
+ aspect: "portrait",
276
+ overlay: "medium",
277
+ labelAlign: "center",
278
+ rounded: false,
279
+ items: [],
280
+ } satisfies LinkTilesProps as unknown as BlockProps,
281
+ hasInlineContent: true,
282
+ },
283
+ cabinTabs: {
284
+ type: "cabinTabs",
285
+ label: "Cabin tabs",
286
+ description:
287
+ "Clickable categories — each tab has its own text, bullet list and image gallery.",
288
+ category: "content",
289
+ component: ({ props }) => <CabinTabsBlock props={props as CabinTabsProps} />,
290
+ defaultProps: {
291
+ tone: "white",
292
+ items: [],
293
+ } satisfies CabinTabsProps as unknown as BlockProps,
294
+ hasInlineContent: true,
295
+ },
296
+ priceTable: {
297
+ type: "priceTable",
298
+ label: "Price table",
299
+ description:
300
+ "Editable price cards — one card per category with a price row per number of guests.",
301
+ category: "content",
302
+ component: ({ props }) => <PriceTableBlock props={props as PriceTableProps} />,
303
+ defaultProps: {
304
+ tone: "paper",
305
+ columns: 3,
306
+ align: "center",
307
+
308
+ groups: [],
309
+ } satisfies PriceTableProps as unknown as BlockProps,
310
+ hasInlineContent: true,
311
+ },
312
+ };
313
+
314
+ /**
315
+ * Build a site registry from the shared blocks plus the site's own defs.
316
+ * Site defs win on key collisions so a site can override a shared block.
317
+ */
318
+ export function createBlockRegistry(
319
+ siteDefs: Record<string, BlockDef> = {},
320
+ ): Record<string, BlockDef> {
321
+ return { ...SHARED_BLOCK_DEFS, ...siteDefs };
322
+ }