@bygghammar/cms-platform 0.75.0 → 0.77.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/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
1
+ ## 0.77.0
2
+
3
+ - Global Sections list: preview thumbnails now use the section's first block type
4
+ and show a text snippet from the saved content (including Rich text `html`),
5
+ instead of a blank generic placeholder. Multi-block sections show a count badge.
6
+ - Shared thumbnail: text detection reads `html`, `body`, `lead`, `subtitle` and
7
+ `heading`, with locale fallback and HTML stripped to plain text.
8
+
9
+ ## 0.76.0
10
+
11
+ - Rich text editor colours: swatches are now derived from the consuming site's
12
+ design tokens (brand, accent, ink, paper, tint) plus a neutral row, a custom
13
+ hex field and a per-browser "recent colours" row.
14
+ - Rich text editor: toolbar buttons for a manual line break (Shift+Enter) and
15
+ "keep words together" (no wrap), so headings break exactly where you want.
16
+ - Sanitizer allows the `white-space` inline property used by the no-wrap mark.
17
+
1
18
  ## 0.75.0
2
19
 
3
20
  - Countdown: pick the replacement global block from a dropdown instead of typing its key.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.75.0",
3
+ "version": "0.77.0",
4
4
  "description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -27,6 +27,24 @@ export type GlobalSectionsListDeps = {
27
27
  previewSrc: (key: string) => string;
28
28
  };
29
29
 
30
+ /** Saved sections store an array of block instances; shapes may vary by age. */
31
+ function blockList(blocks: unknown): Record<string, unknown>[] {
32
+ if (!Array.isArray(blocks)) return [];
33
+ return blocks.filter((b): b is Record<string, unknown> => !!b && typeof b === "object");
34
+ }
35
+
36
+ function blockCount(blocks: unknown): number {
37
+ return blockList(blocks).length;
38
+ }
39
+
40
+ /** Block type of the first block, so the thumbnail can draw its wireframe. */
41
+ function firstBlockType(blocks: unknown): string | undefined {
42
+ const first = blockList(blocks)[0];
43
+ const type = first?.["type"];
44
+ return typeof type === "string" && type ? type : undefined;
45
+ }
46
+
47
+
30
48
  /**
31
49
  * Library screen listing global sections — reusable groups of blocks that update
32
50
  * everywhere at once. All data access and navigation is host-supplied.
@@ -98,7 +116,12 @@ export function GlobalSectionsListView({ deps }: { deps: GlobalSectionsListDeps
98
116
  onClick={() => setPreview({ key: g.key, name: g.name })}
99
117
  className="group relative block"
100
118
  >
101
- <DataThumb data={g.blocks} />
119
+ <DataThumb data={g.blocks} blockType={firstBlockType(g.blocks)} />
120
+ {blockCount(g.blocks) > 1 && (
121
+ <span className="absolute bottom-1 right-1 rounded bg-ink/70 px-1 text-[0.55rem] leading-4 text-white">
122
+ {blockCount(g.blocks)}
123
+ </span>
124
+ )}
102
125
  <span className="absolute inset-0 hidden items-center justify-center bg-ink/50 text-white group-hover:flex">
103
126
  <Eye className="size-4" />
104
127
  </span>
@@ -1,5 +1,5 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
- import { EditorContent, useEditor, type Editor } from "@tiptap/react";
2
+ import { EditorContent, Extension, useEditor, type Editor } from "@tiptap/react";
3
3
  import StarterKit from "@tiptap/starter-kit";
4
4
  import Image from "@tiptap/extension-image";
5
5
  import TextAlign from "@tiptap/extension-text-align";
@@ -26,6 +26,7 @@ import {
26
26
  Minus,
27
27
  Quote,
28
28
  Redo2,
29
+ WrapText,
29
30
  Strikethrough,
30
31
  Subscript as SubIcon,
31
32
  Superscript as SupIcon,
@@ -33,9 +34,18 @@ import {
33
34
  Underline as UnderlineIcon,
34
35
  Undo2,
35
36
  Unlink,
37
+ MoveHorizontal,
36
38
  } from "lucide-react";
37
39
  import { MediaPickerDialog } from "./inspectors/fields";
38
40
  import { sanitizeHtml } from "../html/sanitize-html";
41
+ import {
42
+ NEUTRAL_SWATCHES,
43
+ normalizeHex,
44
+ readRecentColors,
45
+ rememberRecentColor,
46
+ resolveThemeSwatches,
47
+ type EditorSwatch,
48
+ } from "./editor-colors";
39
49
 
40
50
  const FONTS: { label: string; value: string }[] = [
41
51
  { label: "Theme (default)", value: "" },
@@ -50,20 +60,28 @@ const FONTS: { label: string; value: string }[] = [
50
60
 
51
61
  const SIZES = ["", "12px", "14px", "16px", "18px", "20px", "24px", "30px", "36px", "48px", "60px"];
52
62
 
53
- const SWATCHES = [
54
- "#0f1720",
55
- "#4b5563",
56
- "#9ca3af",
57
- "#ffffff",
58
- "#2ac4ea",
59
- "#d8f2fd",
60
- "#afafaf",
61
- "#0ea5e9",
62
- "#16a34a",
63
- "#eab308",
64
- "#f97316",
65
- "#dc2626",
66
- ];
63
+ /**
64
+ * `white-space` on the textStyle mark, so a selection can be marked
65
+ * "keep words together" and never wrap mid-phrase.
66
+ */
67
+ const WhiteSpace = Extension.create({
68
+ name: "whiteSpaceTextStyle",
69
+ addGlobalAttributes() {
70
+ return [
71
+ {
72
+ types: ["textStyle"],
73
+ attributes: {
74
+ whiteSpace: {
75
+ default: null,
76
+ parseHTML: (element: HTMLElement) => element.style.whiteSpace || null,
77
+ renderHTML: (attributes: Record<string, unknown>) =>
78
+ attributes.whiteSpace ? { style: `white-space: ${String(attributes.whiteSpace)}` } : {},
79
+ },
80
+ },
81
+ },
82
+ ];
83
+ },
84
+ });
67
85
 
68
86
  function Btn({
69
87
  on,
@@ -101,6 +119,26 @@ function Divider() {
101
119
  return <span className="mx-1 h-5 w-px bg-ink/10" />;
102
120
  }
103
121
 
122
+ function SwatchButton({
123
+ swatch,
124
+ onPick,
125
+ }: {
126
+ swatch: EditorSwatch;
127
+ onPick: (hex: string) => void;
128
+ }) {
129
+ return (
130
+ <button
131
+ type="button"
132
+ onMouseDown={(e) => e.preventDefault()}
133
+ onClick={() => onPick(swatch.hex)}
134
+ className="size-5 border border-ink/15"
135
+ style={{ background: swatch.hex }}
136
+ title={`${swatch.label} (${swatch.hex})`}
137
+ aria-label={`${swatch.label} ${swatch.hex}`}
138
+ />
139
+ );
140
+ }
141
+
104
142
  function ColorControl({
105
143
  label,
106
144
  value,
@@ -113,11 +151,36 @@ function ColorControl({
113
151
  onClear: () => void;
114
152
  }) {
115
153
  const [open, setOpen] = useState(false);
154
+ const [theme, setTheme] = useState<EditorSwatch[]>([]);
155
+ const [recent, setRecent] = useState<string[]>([]);
156
+ const [hexDraft, setHexDraft] = useState("");
157
+ const [hexError, setHexError] = useState(false);
158
+
159
+ useEffect(() => {
160
+ if (!open) return;
161
+ setTheme(resolveThemeSwatches());
162
+ setRecent(readRecentColors());
163
+ setHexDraft(normalizeHex(value) ?? "");
164
+ setHexError(false);
165
+ }, [open, value]);
166
+
167
+ const apply = (hex: string, close = true) => {
168
+ const normalized = normalizeHex(hex);
169
+ if (!normalized) {
170
+ setHexError(true);
171
+ return;
172
+ }
173
+ onPick(normalized);
174
+ setRecent(rememberRecentColor(normalized));
175
+ setHexError(false);
176
+ if (close) setOpen(false);
177
+ };
178
+
116
179
  return (
117
180
  <div className="relative">
118
181
  <button
119
182
  type="button"
120
- title={label}
183
+ title={label === "A" ? "Text colour" : "Highlight colour"}
121
184
  onMouseDown={(e) => e.preventDefault()}
122
185
  onClick={() => setOpen((o) => !o)}
123
186
  className="flex h-7 items-center gap-1 border border-transparent px-1.5 text-[0.65rem] uppercase tracking-widest text-ink hover:border-ink/20"
@@ -131,29 +194,79 @@ function ColorControl({
131
194
  {open ? (
132
195
  <>
133
196
  <div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
134
- <div className="absolute left-0 top-8 z-50 w-44 border border-ink/15 bg-white p-2 shadow-lg">
197
+ <div className="absolute left-0 top-8 z-50 w-56 border border-ink/15 bg-white p-2 shadow-lg">
198
+ {theme.length ? (
199
+ <>
200
+ <p className="mb-1 text-[0.6rem] uppercase tracking-widest text-ink/50">
201
+ Site colours
202
+ </p>
203
+ <div className="grid grid-cols-6 gap-1">
204
+ {theme.map((sw) => (
205
+ <SwatchButton key={`theme-${sw.label}`} swatch={sw} onPick={apply} />
206
+ ))}
207
+ </div>
208
+ </>
209
+ ) : null}
210
+
211
+ <p className="mb-1 mt-2 text-[0.6rem] uppercase tracking-widest text-ink/50">
212
+ Neutral
213
+ </p>
135
214
  <div className="grid grid-cols-6 gap-1">
136
- {SWATCHES.map((c) => (
137
- <button
138
- key={c}
139
- type="button"
140
- onMouseDown={(e) => e.preventDefault()}
141
- onClick={() => {
142
- onPick(c);
143
- setOpen(false);
144
- }}
145
- className="size-5 border border-ink/15"
146
- style={{ background: c }}
147
- title={c}
148
- />
215
+ {NEUTRAL_SWATCHES.map((sw) => (
216
+ <SwatchButton key={sw.hex} swatch={sw} onPick={apply} />
149
217
  ))}
150
218
  </div>
151
- <div className="mt-2 flex items-center gap-2">
219
+
220
+ {recent.length ? (
221
+ <>
222
+ <p className="mb-1 mt-2 text-[0.6rem] uppercase tracking-widest text-ink/50">
223
+ Recent
224
+ </p>
225
+ <div className="grid grid-cols-6 gap-1">
226
+ {recent.map((hex) => (
227
+ <SwatchButton key={`recent-${hex}`} swatch={{ hex, label: "Recent" }} onPick={apply} />
228
+ ))}
229
+ </div>
230
+ </>
231
+ ) : null}
232
+
233
+ <p className="mb-1 mt-2 text-[0.6rem] uppercase tracking-widest text-ink/50">
234
+ Custom
235
+ </p>
236
+ <div className="flex items-center gap-2">
152
237
  <input
153
238
  type="color"
154
- value={value || "#000000"}
155
- onChange={(e) => onPick(e.target.value)}
239
+ value={normalizeHex(value) ?? "#000000"}
240
+ onChange={(e) => {
241
+ setHexDraft(e.target.value);
242
+ apply(e.target.value, false);
243
+ }}
156
244
  className="h-6 w-10 border border-ink/15 bg-white"
245
+ title="Colour picker"
246
+ />
247
+ <input
248
+ type="text"
249
+ value={hexDraft}
250
+ placeholder="#fc7aa7"
251
+ spellCheck={false}
252
+ onChange={(e) => {
253
+ setHexDraft(e.target.value);
254
+ setHexError(false);
255
+ }}
256
+ onKeyDown={(e) => {
257
+ if (e.key === "Enter") {
258
+ e.preventDefault();
259
+ apply(hexDraft);
260
+ }
261
+ }}
262
+ onBlur={() => {
263
+ if (hexDraft.trim()) apply(hexDraft, false);
264
+ }}
265
+ className={[
266
+ "h-6 w-20 border bg-white px-1 text-xs text-ink focus:outline-none",
267
+ hexError ? "border-red-500" : "border-ink/15 focus:border-sea",
268
+ ].join(" ")}
269
+ aria-label="Custom hex colour"
157
270
  />
158
271
  <button
159
272
  type="button"
@@ -167,6 +280,9 @@ function ColorControl({
167
280
  Clear
168
281
  </button>
169
282
  </div>
283
+ {hexError ? (
284
+ <p className="mt-1 text-[0.6rem] text-red-600">Enter a hex colour like #fc7aa7.</p>
285
+ ) : null}
170
286
  </div>
171
287
  </>
172
288
  ) : null}
@@ -335,6 +451,28 @@ function Toolbar({ editor }: { editor: Editor }) {
335
451
  />
336
452
  <Divider />
337
453
 
454
+ <Btn
455
+ on={() => editor.chain().focus().setHardBreak().run()}
456
+ title="Line break (Shift+Enter)"
457
+ >
458
+ <WrapText className="size-3.5" />
459
+ </Btn>
460
+ <Btn
461
+ on={() => {
462
+ const active = editor.getAttributes("textStyle").whiteSpace === "nowrap";
463
+ editor
464
+ .chain()
465
+ .focus()
466
+ .setMark("textStyle", { whiteSpace: active ? null : "nowrap" })
467
+ .run();
468
+ }}
469
+ active={editor.getAttributes("textStyle").whiteSpace === "nowrap"}
470
+ title="Keep words together (no line wrap)"
471
+ >
472
+ <MoveHorizontal className="size-3.5" />
473
+ </Btn>
474
+ <Divider />
475
+
338
476
  <Btn
339
477
  on={() => editor.chain().focus().setTextAlign("left").run()}
340
478
  active={editor.isActive({ textAlign: "left" })}
@@ -531,6 +669,7 @@ export function RichTextEditor({
531
669
  FontFamily,
532
670
  FontSize,
533
671
  LineHeight,
672
+ WhiteSpace,
534
673
  Highlight.configure({ multicolor: true }),
535
674
  TextAlign.configure({ types: ["heading", "paragraph"] }),
536
675
  Subscript,
@@ -64,6 +64,42 @@ function findImage(node: unknown, depth = 0): string | null {
64
64
  return null;
65
65
  }
66
66
 
67
+ const ENTITIES: Record<string, string> = {
68
+ "&nbsp;": " ",
69
+ "&amp;": "&",
70
+ "&lt;": "<",
71
+ "&gt;": ">",
72
+ "&quot;": '"',
73
+ "&#39;": "'",
74
+ "&apos;": "'",
75
+ };
76
+
77
+ /** Plain-text snippet from sanitized editor HTML (no DOM needed, SSR-safe). */
78
+ function htmlToText(html: string): string {
79
+ const withBreaks = html.replace(/<(br|\/p|\/h[1-6]|\/li|\/div)[^>]*>/gi, " ");
80
+ const stripped = withBreaks.replace(/<[^>]*>/g, "");
81
+ const decoded = stripped
82
+ .replace(/&nbsp;|&amp;|&lt;|&gt;|&quot;|&#39;|&apos;/gi, (m) => ENTITIES[m.toLowerCase()] ?? m)
83
+ .replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)));
84
+ return decoded.replace(/\s+/g, " ").trim();
85
+ }
86
+
87
+ /** Text keys, in the order a block would show them. */
88
+ const TEXT_KEYS = ["title", "heading", "name", "label", "eyebrow", "html", "body", "lead", "subtitle"];
89
+
90
+ /** Picks a string out of a plain string or a localized map (active locale first). */
91
+ function localizedString(value: unknown): string | null {
92
+ if (typeof value === "string") return value.trim() ? value : null;
93
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
94
+ const map = value as Record<string, unknown>;
95
+ const ordered = ["sv", "en", ...Object.keys(map)];
96
+ for (const locale of ordered) {
97
+ const candidate = map[locale];
98
+ if (typeof candidate === "string" && candidate.trim()) return candidate;
99
+ }
100
+ return null;
101
+ }
102
+
67
103
  function findText(node: unknown, depth = 0): string | null {
68
104
  if (node == null || depth > 4) return null;
69
105
  if (Array.isArray(node)) {
@@ -75,13 +111,16 @@ function findText(node: unknown, depth = 0): string | null {
75
111
  }
76
112
  if (typeof node !== "object") return null;
77
113
  const obj = node as Record<string, unknown>;
78
- for (const key of ["title", "name", "label", "eyebrow"]) {
79
- const value = obj[key];
80
- if (typeof value === "string" && value.trim()) return value;
81
- if (value && typeof value === "object") {
82
- const sv = (value as Record<string, unknown>).sv;
83
- if (typeof sv === "string" && sv.trim()) return sv;
84
- }
114
+ const nested = obj["props"];
115
+ if (nested && typeof nested === "object") {
116
+ const found = findText(nested, depth + 1);
117
+ if (found) return found;
118
+ }
119
+ for (const key of TEXT_KEYS) {
120
+ const raw = localizedString(obj[key]);
121
+ if (!raw) continue;
122
+ const text = key === "html" || /<[a-z][^>]*>/i.test(raw) ? htmlToText(raw) : raw.trim();
123
+ if (text) return text.length > 120 ? `${text.slice(0, 120)}…` : text;
85
124
  }
86
125
  return null;
87
126
  }
@@ -372,7 +411,7 @@ export function DataThumb({
372
411
 
373
412
  return (
374
413
  <div className="relative shrink-0 border border-ink/10 bg-white overflow-hidden" style={style}>
375
- {text && !blockType ? (
414
+ {text ? (
376
415
  <div className="flex size-full flex-col justify-center gap-1.5 p-2">
377
416
  <div className="h-1 w-6 bg-sea/60" />
378
417
  <div className="text-[0.5rem] leading-tight text-ink/70 line-clamp-2 font-serif">
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Colour palette helpers for the rich text editor.
3
+ *
4
+ * The editor swatches are derived from the *consuming site's* design tokens, so
5
+ * every site automatically offers its own brand colours without configuration.
6
+ * Tokens may be authored in any colour space (oklch, hsl, hex), so values are
7
+ * resolved through the browser and normalised to hex, which is what the colour
8
+ * input and the stored inline styles need.
9
+ */
10
+
11
+ export type EditorSwatch = { hex: string; label: string };
12
+
13
+ /** Site design tokens, in the order they should appear in the picker. */
14
+ const THEME_TOKENS: { token: string; label: string; fallback: string }[] = [
15
+ { token: "--sea", label: "Brand", fallback: "#fc7aa7" },
16
+ { token: "--brass", label: "Accent", fallback: "#ca6286" },
17
+ { token: "--ink", label: "Ink", fallback: "#221b26" },
18
+ { token: "--paper", label: "Paper", fallback: "#fdf7f8" },
19
+ { token: "--ice", label: "Tint", fallback: "#f8e7ec" },
20
+ ];
21
+
22
+ /** Neutral + status colours that stay available on every site. */
23
+ export const NEUTRAL_SWATCHES: EditorSwatch[] = [
24
+ { hex: "#000000", label: "Black" },
25
+ { hex: "#4b5563", label: "Dark grey" },
26
+ { hex: "#9ca3af", label: "Grey" },
27
+ { hex: "#e5e7eb", label: "Light grey" },
28
+ { hex: "#ffffff", label: "White" },
29
+ { hex: "#16a34a", label: "Green" },
30
+ { hex: "#eab308", label: "Yellow" },
31
+ { hex: "#f97316", label: "Orange" },
32
+ { hex: "#dc2626", label: "Red" },
33
+ { hex: "#0ea5e9", label: "Blue" },
34
+ ];
35
+
36
+ const HEX_RE = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
37
+
38
+ /** Normalise user input such as `fc7aa7` or `#FFF` to `#rrggbb`. */
39
+ export function normalizeHex(raw: string): string | null {
40
+ const v = raw.trim().toLowerCase();
41
+ const withHash = v.startsWith("#") ? v : `#${v}`;
42
+ if (!HEX_RE.test(withHash)) return null;
43
+ if (withHash.length === 4) {
44
+ const [, r, g, b] = withHash;
45
+ return `#${r}${r}${g}${g}${b}${b}`;
46
+ }
47
+ return withHash;
48
+ }
49
+
50
+ function toHex(cssColor: string): string | null {
51
+ if (!cssColor) return null;
52
+ const direct = normalizeHex(cssColor);
53
+ if (direct) return direct;
54
+ try {
55
+ const canvas = document.createElement("canvas");
56
+ canvas.width = 1;
57
+ canvas.height = 1;
58
+ const ctx = canvas.getContext("2d");
59
+ if (!ctx) return null;
60
+ ctx.fillStyle = "#000000";
61
+ ctx.fillStyle = cssColor;
62
+ ctx.fillRect(0, 0, 1, 1);
63
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
64
+ const hex = (n: number) => n.toString(16).padStart(2, "0");
65
+ return `#${hex(r)}${hex(g)}${hex(b)}`;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /** Resolve the site theme swatches from the live CSS custom properties. */
72
+ export function resolveThemeSwatches(): EditorSwatch[] {
73
+ if (typeof document === "undefined") {
74
+ return THEME_TOKENS.map((t) => ({ hex: t.fallback, label: t.label }));
75
+ }
76
+ const probe = document.createElement("span");
77
+ probe.style.position = "absolute";
78
+ probe.style.opacity = "0";
79
+ probe.style.pointerEvents = "none";
80
+ document.body.appendChild(probe);
81
+ try {
82
+ return THEME_TOKENS.map((t) => {
83
+ probe.style.color = "";
84
+ probe.style.color = `var(${t.token})`;
85
+ const computed = getComputedStyle(probe).color;
86
+ return { hex: toHex(computed) ?? t.fallback, label: t.label };
87
+ });
88
+ } catch {
89
+ return THEME_TOKENS.map((t) => ({ hex: t.fallback, label: t.label }));
90
+ } finally {
91
+ probe.remove();
92
+ }
93
+ }
94
+
95
+ const RECENT_KEY = "cms.rte.recent-colors";
96
+ const RECENT_MAX = 8;
97
+
98
+ export function readRecentColors(): string[] {
99
+ if (typeof localStorage === "undefined") return [];
100
+ try {
101
+ const raw = localStorage.getItem(RECENT_KEY);
102
+ if (!raw) return [];
103
+ const parsed: unknown = JSON.parse(raw);
104
+ if (!Array.isArray(parsed)) return [];
105
+ return parsed
106
+ .filter((v): v is string => typeof v === "string")
107
+ .map((v) => normalizeHex(v))
108
+ .filter((v): v is string => Boolean(v))
109
+ .slice(0, RECENT_MAX);
110
+ } catch {
111
+ return [];
112
+ }
113
+ }
114
+
115
+ export function rememberRecentColor(hex: string): string[] {
116
+ const normalized = normalizeHex(hex);
117
+ if (!normalized) return readRecentColors();
118
+ const next = [normalized, ...readRecentColors().filter((c) => c !== normalized)].slice(
119
+ 0,
120
+ RECENT_MAX,
121
+ );
122
+ if (typeof localStorage !== "undefined") {
123
+ try {
124
+ localStorage.setItem(RECENT_KEY, JSON.stringify(next));
125
+ } catch {
126
+ /* storage unavailable — recents are a convenience only */
127
+ }
128
+ }
129
+ return next;
130
+ }
@@ -48,6 +48,7 @@ const ALLOWED_CSS = new Set([
48
48
  "letter-spacing",
49
49
  "text-decoration",
50
50
  "text-transform",
51
+ "white-space",
51
52
  "width",
52
53
  "height",
53
54
  "min-width",