@bygghammar/cms-platform 0.74.0 → 0.76.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,16 @@
|
|
|
1
|
+
## 0.76.0
|
|
2
|
+
|
|
3
|
+
- Rich text editor colours: swatches are now derived from the consuming site's
|
|
4
|
+
design tokens (brand, accent, ink, paper, tint) plus a neutral row, a custom
|
|
5
|
+
hex field and a per-browser "recent colours" row.
|
|
6
|
+
- Rich text editor: toolbar buttons for a manual line break (Shift+Enter) and
|
|
7
|
+
"keep words together" (no wrap), so headings break exactly where you want.
|
|
8
|
+
- Sanitizer allows the `white-space` inline property used by the no-wrap mark.
|
|
9
|
+
|
|
10
|
+
## 0.75.0
|
|
11
|
+
|
|
12
|
+
- Countdown: pick the replacement global block from a dropdown instead of typing its key.
|
|
13
|
+
|
|
1
14
|
# Changelog
|
|
2
15
|
|
|
3
16
|
## 0.74.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.76.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": [
|
|
@@ -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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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-
|
|
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
|
-
{
|
|
137
|
-
<
|
|
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
|
-
|
|
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
|
|
155
|
-
onChange={(e) =>
|
|
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,
|
|
@@ -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
|
+
}
|
|
@@ -2413,7 +2413,11 @@ export {
|
|
|
2413
2413
|
};
|
|
2414
2414
|
|
|
2415
2415
|
/* ---------------- Countdown ---------------- */
|
|
2416
|
-
export function CountdownInspector({
|
|
2416
|
+
export function CountdownInspector({
|
|
2417
|
+
block,
|
|
2418
|
+
patch,
|
|
2419
|
+
globalSections = [],
|
|
2420
|
+
}: BlockPatchProps & { globalSections?: { key: string; name: string }[] }) {
|
|
2417
2421
|
const p = (block.props ?? {}) as CountdownProps;
|
|
2418
2422
|
const update = (next: CountdownProps) => patch(next as unknown as BlockProps);
|
|
2419
2423
|
|
|
@@ -2711,11 +2715,37 @@ export function CountdownInspector({ block, patch }: BlockPatchProps) {
|
|
|
2711
2715
|
(v) => update({ ...p, onExpire: v as CountdownProps["onExpire"] }),
|
|
2712
2716
|
)}
|
|
2713
2717
|
{p.onExpire === "replace" ? (
|
|
2714
|
-
|
|
2715
|
-
label="
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2718
|
+
globalSections.length ? (
|
|
2719
|
+
<label className="block space-y-1.5">
|
|
2720
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
|
|
2721
|
+
Global block to show instead
|
|
2722
|
+
</span>
|
|
2723
|
+
<select
|
|
2724
|
+
value={p.expiredSectionKey ?? ""}
|
|
2725
|
+
onChange={(e) => update({ ...p, expiredSectionKey: e.target.value })}
|
|
2726
|
+
className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:border-sea focus:outline-none"
|
|
2727
|
+
>
|
|
2728
|
+
<option value="">— none —</option>
|
|
2729
|
+
{globalSections.map((g) => (
|
|
2730
|
+
<option key={g.key} value={g.key}>
|
|
2731
|
+
{g.name} ({g.key})
|
|
2732
|
+
</option>
|
|
2733
|
+
))}
|
|
2734
|
+
{p.expiredSectionKey &&
|
|
2735
|
+
!globalSections.some((g) => g.key === p.expiredSectionKey) ? (
|
|
2736
|
+
<option value={p.expiredSectionKey}>
|
|
2737
|
+
{p.expiredSectionKey} (missing)
|
|
2738
|
+
</option>
|
|
2739
|
+
) : null}
|
|
2740
|
+
</select>
|
|
2741
|
+
</label>
|
|
2742
|
+
) : (
|
|
2743
|
+
<TextField
|
|
2744
|
+
label="Global block key to show instead"
|
|
2745
|
+
value={p.expiredSectionKey ?? ""}
|
|
2746
|
+
onChange={(v) => update({ ...p, expiredSectionKey: v })}
|
|
2747
|
+
/>
|
|
2748
|
+
)
|
|
2719
2749
|
) : null}
|
|
2720
2750
|
{p.onExpire === "revealPage" ? (
|
|
2721
2751
|
<>
|
|
@@ -750,7 +750,7 @@ export function BlockInspector({
|
|
|
750
750
|
case "courseList":
|
|
751
751
|
return <TripListInspector block={block} patch={patch} />;
|
|
752
752
|
case "countdown":
|
|
753
|
-
return <CountdownInspector block={block} patch={patch} />;
|
|
753
|
+
return <CountdownInspector block={block} patch={patch} globalSections={globalSections} />;
|
|
754
754
|
case "ctaBanner":
|
|
755
755
|
return <CtaBannerInspector block={block} patch={patch} />;
|
|
756
756
|
case "mediaSplit":
|