@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,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small allowlist HTML sanitizer, safe to run on the server (no DOM needed).
|
|
3
|
+
*
|
|
4
|
+
* Rich-text blocks store HTML produced by the backoffice editor. Even though
|
|
5
|
+
* only staff can author it, the output is sanitized on every render so a
|
|
6
|
+
* pasted `<script>`, inline event handler or `javascript:` URL can never end
|
|
7
|
+
* up in a page.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const ALLOWED_TAGS = new Set([
|
|
11
|
+
"p", "br", "hr", "span", "div",
|
|
12
|
+
"h1", "h2", "h3", "h4", "h5", "h6",
|
|
13
|
+
"strong", "b", "em", "i", "u", "s", "del", "ins", "mark", "small",
|
|
14
|
+
"sub", "sup", "code", "pre", "kbd", "blockquote",
|
|
15
|
+
"ul", "ol", "li",
|
|
16
|
+
"a", "img", "figure", "figcaption",
|
|
17
|
+
"table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "colgroup", "col",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
/** Tags whose entire contents are dropped. */
|
|
21
|
+
const DROP_WITH_CONTENT = new Set([
|
|
22
|
+
"script", "style", "iframe", "object", "embed", "noscript", "template", "svg", "math",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const VOID_TAGS = new Set(["br", "hr", "img", "col"]);
|
|
26
|
+
|
|
27
|
+
const GLOBAL_ATTRS = new Set(["class", "style", "id", "title", "dir", "lang"]);
|
|
28
|
+
const TAG_ATTRS: Record<string, Set<string>> = {
|
|
29
|
+
a: new Set(["href", "target", "rel", "download"]),
|
|
30
|
+
img: new Set(["src", "alt", "width", "height", "loading", "srcset", "sizes"]),
|
|
31
|
+
td: new Set(["colspan", "rowspan", "headers"]),
|
|
32
|
+
th: new Set(["colspan", "rowspan", "scope", "headers"]),
|
|
33
|
+
col: new Set(["span", "width"]),
|
|
34
|
+
colgroup: new Set(["span"]),
|
|
35
|
+
ol: new Set(["start", "type"]),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** CSS properties the editor is allowed to emit inline. */
|
|
39
|
+
const ALLOWED_CSS = new Set([
|
|
40
|
+
"color",
|
|
41
|
+
"background-color",
|
|
42
|
+
"text-align",
|
|
43
|
+
"font-family",
|
|
44
|
+
"font-size",
|
|
45
|
+
"font-weight",
|
|
46
|
+
"font-style",
|
|
47
|
+
"line-height",
|
|
48
|
+
"letter-spacing",
|
|
49
|
+
"text-decoration",
|
|
50
|
+
"text-transform",
|
|
51
|
+
"width",
|
|
52
|
+
"height",
|
|
53
|
+
"min-width",
|
|
54
|
+
"margin",
|
|
55
|
+
"padding",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
const SAFE_URL = /^(https?:\/\/|mailto:|tel:|\/|#|\.\/|\.\.\/)/i;
|
|
59
|
+
|
|
60
|
+
function safeUrl(raw: string): string | null {
|
|
61
|
+
const value = raw.trim().replace(/[\u0000-\u001f]/g, "");
|
|
62
|
+
if (!value) return null;
|
|
63
|
+
if (/^data:image\/(png|jpe?g|gif|webp|avif);base64,/i.test(value)) return value;
|
|
64
|
+
if (!SAFE_URL.test(value)) return null;
|
|
65
|
+
if (/^\s*javascript:/i.test(value)) return null;
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sanitizeStyle(raw: string): string {
|
|
70
|
+
const out: string[] = [];
|
|
71
|
+
for (const decl of raw.split(";")) {
|
|
72
|
+
const idx = decl.indexOf(":");
|
|
73
|
+
if (idx < 0) continue;
|
|
74
|
+
const prop = decl.slice(0, idx).trim().toLowerCase();
|
|
75
|
+
const value = decl.slice(idx + 1).trim();
|
|
76
|
+
if (!ALLOWED_CSS.has(prop)) continue;
|
|
77
|
+
if (!value || /url\s*\(|expression|javascript:|@import/i.test(value)) continue;
|
|
78
|
+
out.push(`${prop}: ${value}`);
|
|
79
|
+
}
|
|
80
|
+
return out.join("; ");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function escapeAttr(v: string): string {
|
|
84
|
+
return v.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const ATTR_RE = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
|
|
88
|
+
|
|
89
|
+
function sanitizeAttrs(tag: string, raw: string): string {
|
|
90
|
+
const allowed = TAG_ATTRS[tag];
|
|
91
|
+
const parts: string[] = [];
|
|
92
|
+
let m: RegExpExecArray | null;
|
|
93
|
+
ATTR_RE.lastIndex = 0;
|
|
94
|
+
while ((m = ATTR_RE.exec(raw))) {
|
|
95
|
+
const name = m[1].toLowerCase();
|
|
96
|
+
let value = m[3] ?? m[4] ?? m[5] ?? "";
|
|
97
|
+
if (name.startsWith("on")) continue;
|
|
98
|
+
if (!GLOBAL_ATTRS.has(name) && !allowed?.has(name)) continue;
|
|
99
|
+
if (name === "href" || name === "src" || name === "srcset") {
|
|
100
|
+
if (name === "srcset") continue;
|
|
101
|
+
const url = safeUrl(value);
|
|
102
|
+
if (!url) continue;
|
|
103
|
+
value = url;
|
|
104
|
+
}
|
|
105
|
+
if (name === "style") {
|
|
106
|
+
value = sanitizeStyle(value);
|
|
107
|
+
if (!value) continue;
|
|
108
|
+
}
|
|
109
|
+
if (name === "target" && value !== "_blank" && value !== "_self") continue;
|
|
110
|
+
parts.push(`${name}="${escapeAttr(value)}"`);
|
|
111
|
+
}
|
|
112
|
+
if (tag === "a") {
|
|
113
|
+
const hasBlank = /target="_blank"/.test(parts.join(" "));
|
|
114
|
+
if (hasBlank && !parts.some((p) => p.startsWith("rel="))) {
|
|
115
|
+
parts.push('rel="noopener noreferrer"');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (tag === "img" && !parts.some((p) => p.startsWith("loading="))) {
|
|
119
|
+
parts.push('loading="lazy"');
|
|
120
|
+
}
|
|
121
|
+
return parts.length ? " " + parts.join(" ") : "";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Sanitize an HTML fragment down to the editor's allowlist. */
|
|
125
|
+
export function sanitizeHtml(input: string | undefined | null): string {
|
|
126
|
+
if (!input) return "";
|
|
127
|
+
// Remove comments and any element whose content must go with it.
|
|
128
|
+
let html = input.replace(/<!--[\s\S]*?-->/g, "");
|
|
129
|
+
for (const tag of DROP_WITH_CONTENT) {
|
|
130
|
+
html = html.replace(new RegExp(`<${tag}[\\s\\S]*?<\\/${tag}\\s*>`, "gi"), "");
|
|
131
|
+
html = html.replace(new RegExp(`<${tag}\\b[^>]*\\/?>`, "gi"), "");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const open: string[] = [];
|
|
135
|
+
let out = "";
|
|
136
|
+
let last = 0;
|
|
137
|
+
const tagRe = /<\s*(\/)?\s*([a-zA-Z][a-zA-Z0-9-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g;
|
|
138
|
+
let m: RegExpExecArray | null;
|
|
139
|
+
|
|
140
|
+
while ((m = tagRe.exec(html))) {
|
|
141
|
+
out += html.slice(last, m.index);
|
|
142
|
+
last = m.index + m[0].length;
|
|
143
|
+
const closing = Boolean(m[1]);
|
|
144
|
+
const tag = m[2].toLowerCase();
|
|
145
|
+
if (!ALLOWED_TAGS.has(tag)) continue;
|
|
146
|
+
|
|
147
|
+
if (closing) {
|
|
148
|
+
if (VOID_TAGS.has(tag)) continue;
|
|
149
|
+
const at = open.lastIndexOf(tag);
|
|
150
|
+
if (at < 0) continue;
|
|
151
|
+
// Close anything opened inside it, then the tag itself.
|
|
152
|
+
while (open.length > at) out += `</${open.pop()}>`;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (VOID_TAGS.has(tag)) {
|
|
157
|
+
out += `<${tag}${sanitizeAttrs(tag, m[3] ?? "")} />`;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
out += `<${tag}${sanitizeAttrs(tag, m[3] ?? "")}>`;
|
|
161
|
+
open.push(tag);
|
|
162
|
+
}
|
|
163
|
+
out += html.slice(last);
|
|
164
|
+
while (open.length) out += `</${open.pop()}>`;
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Plain-text preview of rich text (used for thumbnails and summaries). */
|
|
169
|
+
export function htmlToText(input: string | undefined | null): string {
|
|
170
|
+
return sanitizeHtml(input)
|
|
171
|
+
.replace(/<[^>]+>/g, " ")
|
|
172
|
+
.replace(/ /g, " ")
|
|
173
|
+
.replace(/\s+/g, " ")
|
|
174
|
+
.trim();
|
|
175
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createContext, useContext, useMemo, type ReactNode } from "react";
|
|
2
|
+
import { DEFAULT_LOCALE, type Locale } from "./locales";
|
|
3
|
+
import { t, tImageSrc } from "./resolve";
|
|
4
|
+
import type { LocalizedImage, LocalizedString } from "./types";
|
|
5
|
+
|
|
6
|
+
type LocaleContextValue = { locale: Locale };
|
|
7
|
+
|
|
8
|
+
const LocaleContext = createContext<LocaleContextValue>({
|
|
9
|
+
locale: DEFAULT_LOCALE,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export function LocaleProvider({
|
|
13
|
+
locale = DEFAULT_LOCALE,
|
|
14
|
+
children,
|
|
15
|
+
}: {
|
|
16
|
+
locale?: Locale;
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
}) {
|
|
19
|
+
const value = useMemo(() => ({ locale }), [locale]);
|
|
20
|
+
return (
|
|
21
|
+
<LocaleContext.Provider value={value}>{children}</LocaleContext.Provider>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useLocale(): Locale {
|
|
26
|
+
return useContext(LocaleContext).locale;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Bound helpers for the current locale. Components should route
|
|
31
|
+
* every user-visible string and every image alt through these.
|
|
32
|
+
*/
|
|
33
|
+
export function useT() {
|
|
34
|
+
const locale = useLocale();
|
|
35
|
+
return useMemo(
|
|
36
|
+
() => ({
|
|
37
|
+
locale,
|
|
38
|
+
t: (v: LocalizedString | string | undefined | null) => t(v, locale),
|
|
39
|
+
alt: (img: LocalizedImage) => t(img.alt, locale),
|
|
40
|
+
src: (img: LocalizedImage) => tImageSrc(img, locale),
|
|
41
|
+
}),
|
|
42
|
+
[locale],
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supported UI locales. Swedish is the source of truth (DEFAULT_LOCALE).
|
|
3
|
+
* English is populated from Swedish via auto-translation (see
|
|
4
|
+
* `translations.generated.ts` for static content, and
|
|
5
|
+
* `translate-db.functions.ts` for CMS rows).
|
|
6
|
+
*
|
|
7
|
+
* Adding a locale is a two-step change:
|
|
8
|
+
* 1) add the code here,
|
|
9
|
+
* 2) run the translate script + admin "Translate missing → <locale>" action.
|
|
10
|
+
* No component refactor is required — every user-visible string flows
|
|
11
|
+
* through the resolver, which falls back through the generated map.
|
|
12
|
+
*/
|
|
13
|
+
export const SUPPORTED_LOCALES = ["sv", "en"] as const;
|
|
14
|
+
export type Locale = (typeof SUPPORTED_LOCALES)[number];
|
|
15
|
+
export const DEFAULT_LOCALE: Locale = "sv";
|
|
16
|
+
|
|
17
|
+
export const LOCALE_LABELS: Record<Locale, string> = {
|
|
18
|
+
sv: "SV",
|
|
19
|
+
en: "EN",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function isLocale(v: unknown): v is Locale {
|
|
23
|
+
return typeof v === "string" && (SUPPORTED_LOCALES as readonly string[]).includes(v);
|
|
24
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { DEFAULT_LOCALE, type Locale } from "./locales";
|
|
2
|
+
import type { Localized, LocalizedImage, LocalizedString } from "./types";
|
|
3
|
+
import { getTranslationMaps } from "./translations-registry";
|
|
4
|
+
|
|
5
|
+
function lookup(source: string, locale: Locale): string | undefined {
|
|
6
|
+
const { generated, overrides } = getTranslationMaps();
|
|
7
|
+
const override = overrides[source]?.[locale];
|
|
8
|
+
if (typeof override === "string") return override;
|
|
9
|
+
const auto = generated[source]?.[locale];
|
|
10
|
+
return typeof auto === "string" ? auto : undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolve a localized string.
|
|
16
|
+
*
|
|
17
|
+
* Order:
|
|
18
|
+
* 1) exact value for the requested locale, if present
|
|
19
|
+
* 2) auto-translated value from the generated map, keyed by the
|
|
20
|
+
* Swedish source string
|
|
21
|
+
* 3) the default locale
|
|
22
|
+
* 4) any defined string on the object
|
|
23
|
+
* 5) ""
|
|
24
|
+
*
|
|
25
|
+
* Accepts a plain string for dev ergonomics (returned as-is).
|
|
26
|
+
*/
|
|
27
|
+
export function t(
|
|
28
|
+
value: LocalizedString | string | undefined | null,
|
|
29
|
+
locale: Locale,
|
|
30
|
+
fallback: Locale = DEFAULT_LOCALE,
|
|
31
|
+
): string {
|
|
32
|
+
if (value == null) return "";
|
|
33
|
+
if (typeof value === "string") return value;
|
|
34
|
+
const direct = value[locale];
|
|
35
|
+
if (typeof direct === "string") return direct;
|
|
36
|
+
if (locale !== fallback) {
|
|
37
|
+
const sourceKey = value[fallback];
|
|
38
|
+
if (typeof sourceKey === "string") {
|
|
39
|
+
const translated = lookup(sourceKey, locale);
|
|
40
|
+
if (typeof translated === "string") return translated;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
}
|
|
44
|
+
return (
|
|
45
|
+
value[fallback] ??
|
|
46
|
+
Object.values(value).find((v): v is string => typeof v === "string") ??
|
|
47
|
+
""
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Resolve any localized value with the same fallback logic. */
|
|
52
|
+
export function tv<T>(
|
|
53
|
+
value: Localized<T> | undefined | null,
|
|
54
|
+
locale: Locale,
|
|
55
|
+
fallback: Locale = DEFAULT_LOCALE,
|
|
56
|
+
): T | undefined {
|
|
57
|
+
if (value == null) return undefined;
|
|
58
|
+
const direct = value[locale];
|
|
59
|
+
if (direct !== undefined) return direct;
|
|
60
|
+
// String-map auto-translation fallback (used by slug maps etc.)
|
|
61
|
+
if (locale !== fallback) {
|
|
62
|
+
const src = value[fallback];
|
|
63
|
+
if (typeof src === "string") {
|
|
64
|
+
const translated = lookup(src, locale);
|
|
65
|
+
if (typeof translated === "string") return translated as unknown as T;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
}
|
|
69
|
+
return (
|
|
70
|
+
value[fallback] ??
|
|
71
|
+
(Object.values(value).find((v) => v !== undefined) as T | undefined)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Resolve the correct src for a locale, falling back to shared src. */
|
|
76
|
+
export function tImageSrc(
|
|
77
|
+
image: LocalizedImage,
|
|
78
|
+
locale: Locale,
|
|
79
|
+
fallback: Locale = DEFAULT_LOCALE,
|
|
80
|
+
): string {
|
|
81
|
+
return tv(image.srcByLocale, locale, fallback) ?? image.src;
|
|
82
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Locale } from "./locales";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Per-site translation data. The package owns the resolver logic; each site
|
|
5
|
+
* owns its own generated/overridden strings and registers them at startup.
|
|
6
|
+
* This keeps content per-site while the resolution rules stay shared.
|
|
7
|
+
*/
|
|
8
|
+
export type TranslationMap = Record<string, Partial<Record<Locale, string>>>;
|
|
9
|
+
|
|
10
|
+
let generated: TranslationMap = {};
|
|
11
|
+
let overrides: TranslationMap = {};
|
|
12
|
+
|
|
13
|
+
export function registerTranslations(maps: {
|
|
14
|
+
generated?: TranslationMap;
|
|
15
|
+
overrides?: TranslationMap;
|
|
16
|
+
}): void {
|
|
17
|
+
if (maps.generated) generated = maps.generated;
|
|
18
|
+
if (maps.overrides) overrides = maps.overrides;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getTranslationMaps(): {
|
|
22
|
+
generated: TranslationMap;
|
|
23
|
+
overrides: TranslationMap;
|
|
24
|
+
} {
|
|
25
|
+
return { generated, overrides };
|
|
26
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Locale } from "./locales";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A value that can differ per locale. Partial so future locales
|
|
5
|
+
* don't force backfilling every field — resolvers fall back to
|
|
6
|
+
* the default locale.
|
|
7
|
+
*/
|
|
8
|
+
export type Localized<T> = Partial<Record<Locale, T>>;
|
|
9
|
+
export type LocalizedString = Localized<string>;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Every user-facing image. `alt` is REQUIRED per locale for SEO
|
|
13
|
+
* and accessibility. `srcByLocale` overrides `src` when a locale
|
|
14
|
+
* needs a different asset (e.g. text baked into the image).
|
|
15
|
+
*/
|
|
16
|
+
export type LocalizedImage = {
|
|
17
|
+
src: string;
|
|
18
|
+
alt: LocalizedString;
|
|
19
|
+
srcByLocale?: Localized<string>;
|
|
20
|
+
width?: number;
|
|
21
|
+
height?: number;
|
|
22
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Locale } from "./locales";
|
|
2
|
+
import type { Localized, LocalizedString } from "./types";
|
|
3
|
+
import { tv } from "./resolve";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Every user-facing video. Poster alt + caption are localized so
|
|
7
|
+
* screen readers and social previews get the right language, and
|
|
8
|
+
* subtitle `tracks` are per-locale for accessibility + SEO.
|
|
9
|
+
*/
|
|
10
|
+
export type LocalizedVideoTrack = {
|
|
11
|
+
src: string;
|
|
12
|
+
kind: "subtitles" | "captions" | "descriptions" | "chapters" | "metadata";
|
|
13
|
+
srclang: Locale;
|
|
14
|
+
label: string;
|
|
15
|
+
default?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type LocalizedVideo = {
|
|
19
|
+
src: string;
|
|
20
|
+
srcByLocale?: Localized<string>;
|
|
21
|
+
poster?: string;
|
|
22
|
+
posterByLocale?: Localized<string>;
|
|
23
|
+
posterAlt: LocalizedString;
|
|
24
|
+
caption: LocalizedString;
|
|
25
|
+
tracks?: LocalizedVideoTrack[];
|
|
26
|
+
width?: number;
|
|
27
|
+
height?: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function tVideoSrc(v: LocalizedVideo, locale: Locale): string {
|
|
31
|
+
return tv(v.srcByLocale, locale) ?? v.src;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function tVideoPoster(v: LocalizedVideo, locale: Locale): string | undefined {
|
|
35
|
+
return tv(v.posterByLocale, locale) ?? v.poster;
|
|
36
|
+
}
|
package/src/index.ts
ADDED
package/src/theme.css
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starter theme — the design-token contract of @bygghammar/cms-platform.
|
|
3
|
+
*
|
|
4
|
+
* Every shared block and backoffice screen reads ONLY these variables, so the
|
|
5
|
+
* package never hardcodes a colour or a font. A new site copies this file into
|
|
6
|
+
* its own `src/styles.css` and overrides the values; existing sites keep the
|
|
7
|
+
* theme they already have. Renaming or removing a variable here is a breaking
|
|
8
|
+
* change for every consuming site.
|
|
9
|
+
*
|
|
10
|
+
* Neutral defaults below are intentionally colour-less so an unthemed site
|
|
11
|
+
* looks obviously unbranded rather than accidentally like another site.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
:root {
|
|
15
|
+
/* Typography */
|
|
16
|
+
--font-serif: ui-serif, Georgia, serif;
|
|
17
|
+
--font-sans: ui-sans-serif, system-ui, sans-serif;
|
|
18
|
+
|
|
19
|
+
/* Radii */
|
|
20
|
+
--radius: 3px;
|
|
21
|
+
--radius-sm: 2px;
|
|
22
|
+
--radius-md: 3px;
|
|
23
|
+
--radius-lg: 4px;
|
|
24
|
+
--radius-xl: 6px;
|
|
25
|
+
|
|
26
|
+
/* Brand palette — the five tokens blocks use for surfaces and accents */
|
|
27
|
+
--paper: oklch(0.985 0 0); /* soft off-white surface */
|
|
28
|
+
--ink: oklch(0.18 0 0); /* near-black text / dark surface */
|
|
29
|
+
--sea: oklch(0.62 0 0); /* primary brand accent */
|
|
30
|
+
--ice: oklch(0.95 0 0); /* pale tinted surface */
|
|
31
|
+
--brass: oklch(0.55 0 0); /* secondary accent */
|
|
32
|
+
|
|
33
|
+
/* shadcn/ui semantic tokens, derived from the palette above */
|
|
34
|
+
--background: var(--paper);
|
|
35
|
+
--foreground: var(--ink);
|
|
36
|
+
--card: oklch(1 0 0);
|
|
37
|
+
--card-foreground: var(--ink);
|
|
38
|
+
--popover: oklch(1 0 0);
|
|
39
|
+
--popover-foreground: var(--ink);
|
|
40
|
+
--primary: var(--sea);
|
|
41
|
+
--primary-foreground: var(--paper);
|
|
42
|
+
--secondary: oklch(0.96 0 0);
|
|
43
|
+
--secondary-foreground: var(--ink);
|
|
44
|
+
--muted: oklch(0.96 0 0);
|
|
45
|
+
--muted-foreground: oklch(0.52 0 0);
|
|
46
|
+
--accent: var(--sea);
|
|
47
|
+
--accent-foreground: var(--paper);
|
|
48
|
+
--destructive: oklch(0.58 0.2 25);
|
|
49
|
+
--destructive-foreground: var(--paper);
|
|
50
|
+
--border: oklch(0.9 0 0);
|
|
51
|
+
--input: oklch(0.9 0 0);
|
|
52
|
+
--ring: var(--sea);
|
|
53
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { lockBodyScroll } from "../util/scroll-lock";
|
|
2
|
+
import { useEffect, type ReactNode } from "react";
|
|
3
|
+
import { X } from "lucide-react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared site lightbox. Single source of truth for overlay behaviour:
|
|
7
|
+
* - click anywhere (backdrop *and* content) closes it
|
|
8
|
+
* - Escape closes it
|
|
9
|
+
* - body scroll is locked while open
|
|
10
|
+
* Content that must stay interactive should stop propagation itself.
|
|
11
|
+
*/
|
|
12
|
+
export function Lightbox({
|
|
13
|
+
open,
|
|
14
|
+
onClose,
|
|
15
|
+
label,
|
|
16
|
+
children,
|
|
17
|
+
contentClassName = "",
|
|
18
|
+
closeOnContentClick = true,
|
|
19
|
+
}: {
|
|
20
|
+
open: boolean;
|
|
21
|
+
onClose: () => void;
|
|
22
|
+
label?: string;
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
contentClassName?: string;
|
|
25
|
+
closeOnContentClick?: boolean;
|
|
26
|
+
}) {
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!open) return;
|
|
29
|
+
const onKey = (e: KeyboardEvent) => {
|
|
30
|
+
if (e.key === "Escape") onClose();
|
|
31
|
+
};
|
|
32
|
+
document.addEventListener("keydown", onKey);
|
|
33
|
+
const unlock = lockBodyScroll();
|
|
34
|
+
return () => {
|
|
35
|
+
document.removeEventListener("keydown", onKey);
|
|
36
|
+
unlock();
|
|
37
|
+
};
|
|
38
|
+
}, [open, onClose]);
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if (!open) return null;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div
|
|
45
|
+
role="dialog"
|
|
46
|
+
aria-modal="true"
|
|
47
|
+
aria-label={label}
|
|
48
|
+
onClick={onClose}
|
|
49
|
+
className="fixed inset-0 z-[100] flex items-center justify-center bg-ink/80 backdrop-blur-md p-4 md:p-6 animate-in fade-in duration-300 cursor-pointer"
|
|
50
|
+
>
|
|
51
|
+
<button
|
|
52
|
+
type="button"
|
|
53
|
+
onClick={onClose}
|
|
54
|
+
aria-label="Stäng"
|
|
55
|
+
className="absolute top-6 right-6 z-10 inline-flex size-11 items-center justify-center border border-paper/30 text-paper transition-colors hover:bg-paper hover:text-ink"
|
|
56
|
+
>
|
|
57
|
+
<X className="size-5" />
|
|
58
|
+
</button>
|
|
59
|
+
<div
|
|
60
|
+
onClick={closeOnContentClick ? undefined : (e) => e.stopPropagation()}
|
|
61
|
+
className={`relative animate-in zoom-in-95 duration-300 ${
|
|
62
|
+
closeOnContentClick ? "cursor-pointer" : "cursor-auto"
|
|
63
|
+
} ${contentClassName}`}
|
|
64
|
+
>
|
|
65
|
+
{children}
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export function Reveal({
|
|
4
|
+
children,
|
|
5
|
+
delay = 0,
|
|
6
|
+
className = "",
|
|
7
|
+
}: {
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
delay?: number;
|
|
10
|
+
className?: string;
|
|
11
|
+
}) {
|
|
12
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
13
|
+
const [shown, setShown] = useState(false);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const el = ref.current;
|
|
17
|
+
if (!el) return;
|
|
18
|
+
const io = new IntersectionObserver(
|
|
19
|
+
(entries) => {
|
|
20
|
+
for (const e of entries) {
|
|
21
|
+
if (e.isIntersecting) {
|
|
22
|
+
setShown(true);
|
|
23
|
+
io.disconnect();
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
{ threshold: 0.12, rootMargin: "0px 0px -8% 0px" },
|
|
29
|
+
);
|
|
30
|
+
io.observe(el);
|
|
31
|
+
return () => io.disconnect();
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
ref={ref}
|
|
37
|
+
style={{ transitionDelay: `${delay}ms` }}
|
|
38
|
+
className={[
|
|
39
|
+
"transition-all duration-[900ms] ease-out will-change-transform",
|
|
40
|
+
shown ? "opacity-100 translate-y-0" : "opacity-0 translate-y-6",
|
|
41
|
+
className,
|
|
42
|
+
].join(" ")}
|
|
43
|
+
>
|
|
44
|
+
{children}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reference-counted body scroll lock.
|
|
3
|
+
*
|
|
4
|
+
* Multiple overlays (lightboxes, modals) can be open or transition between
|
|
5
|
+
* each other at the same time. Naive "save previous value / restore on
|
|
6
|
+
* unmount" locks break in that case: the second lock stores `hidden` as the
|
|
7
|
+
* previous value and restores it on close, leaving the page permanently
|
|
8
|
+
* unscrollable. This keeps a single counter and only restores the original
|
|
9
|
+
* value once every lock is released.
|
|
10
|
+
*/
|
|
11
|
+
let count = 0;
|
|
12
|
+
let previousOverflow = "";
|
|
13
|
+
|
|
14
|
+
export function lockBodyScroll(): () => void {
|
|
15
|
+
if (typeof document === "undefined") return () => {};
|
|
16
|
+
|
|
17
|
+
if (count === 0) {
|
|
18
|
+
previousOverflow = document.body.style.overflow;
|
|
19
|
+
document.body.style.overflow = "hidden";
|
|
20
|
+
}
|
|
21
|
+
count += 1;
|
|
22
|
+
|
|
23
|
+
let released = false;
|
|
24
|
+
return () => {
|
|
25
|
+
if (released) return;
|
|
26
|
+
released = true;
|
|
27
|
+
count = Math.max(0, count - 1);
|
|
28
|
+
if (count === 0) {
|
|
29
|
+
document.body.style.overflow = previousOverflow;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|