@bygghammar/cms-platform 0.90.0-m7.9 → 0.91.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 +241 -0
- package/README.md +23 -1
- package/docs/GOOGLE-SHEETS.md +145 -0
- package/docs/LAYOUT-BLOCKS.md +125 -0
- package/docs/MEDIA-FOLDERS.md +90 -0
- package/docs/NEW-SITE.md +35 -0
- package/docs/PAGES.md +452 -0
- package/docs/SCHEDULING.md +130 -0
- package/docs/TYPOGRAPHY.md +86 -0
- package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
- package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
- package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
- package/package.json +5 -1
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/FormEditorView.tsx +10 -0
- package/src/backoffice/FormSettingsView.tsx +8 -0
- package/src/backoffice/MediaLibraryView.tsx +239 -21
- package/src/backoffice/NavigationView.tsx +43 -0
- package/src/backoffice/PageBuilderView.tsx +193 -36
- package/src/backoffice/PageEditorView.tsx +293 -57
- package/src/backoffice/PagesListView.tsx +294 -19
- package/src/backoffice/ScheduleFields.tsx +151 -0
- package/src/backoffice/SectionFormView.tsx +58 -0
- package/src/backoffice/StatusBadge.tsx +174 -0
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/TypographyPanel.tsx +308 -0
- package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
- package/src/backoffice/inspectors/blocks.tsx +149 -0
- package/src/backoffice/inspectors/fields.tsx +81 -0
- package/src/backoffice/inspectors/index.tsx +3 -0
- package/src/backoffice/media/ConfirmDialog.tsx +64 -4
- package/src/backoffice/routes/host.ts +31 -0
- package/src/backoffice/routes/index.tsx +140 -15
- package/src/backoffice/routes/media-forms.tsx +5 -0
- package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
- package/src/backoffice/seo/page-seo-form.ts +11 -1
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/block-children.tsx +40 -0
- package/src/blocks/block-tree.ts +378 -0
- package/src/blocks/components/IconGridBlock.tsx +76 -51
- package/src/blocks/components/LayoutBlock.tsx +100 -0
- package/src/blocks/countdown-time.ts +17 -87
- package/src/blocks/registry.tsx +21 -0
- package/src/blocks/types.ts +40 -0
- package/src/cms/page-publication.ts +377 -0
- package/src/cms/page-status.ts +152 -0
- package/src/cms/timezone.ts +181 -0
- package/src/cms/use-publication-boundary.ts +84 -0
- package/src/cms/use-schedule-clock.ts +43 -0
- package/src/cms/visibility-schedule.ts +267 -0
- package/src/config/modules.ts +34 -10
- package/src/config/site-config.ts +32 -0
- package/src/config/typography.ts +143 -0
- package/src/http/cache-policy.ts +49 -2
- package/src/media/folders.ts +183 -0
- package/src/media/use-media-upload.ts +21 -4
- package/src/navigation/index.ts +34 -2
- package/src/platform/contract.ts +52 -1
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +16 -0
- package/src/server/domains-core.ts +124 -107
- package/src/server/forms-delivery-core.ts +61 -10
- package/src/server/forms-submissions-core.ts +47 -0
- package/src/server/google-service-account.ts +325 -0
- package/src/server/google-sheets-core.ts +463 -0
- package/src/server/http/sitemap.ts +32 -8
- package/src/server/injections-core.ts +230 -49
- package/src/server/media-library-core.ts +27 -6
- package/src/server/pages-core.ts +680 -184
- package/src/server/published-cache.ts +25 -4
- package/src/server/resolve-globals.ts +6 -1
- package/src/server/schema-drift.ts +4 -0
- package/src/server/schemas.ts +15 -0
- package/src/site/PageRenderer.tsx +45 -12
- package/src/site/SiteFooter.tsx +44 -3
- package/src/site/SiteHeader.tsx +23 -2
- package/src/site/google-fonts.ts +191 -0
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes/public-page.tsx +12 -1
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/site/typography.tsx +75 -0
- package/src/tracking/injections.ts +52 -9
- package/src/ui/MobileCarousel.tsx +289 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical timezone maths for the whole platform.
|
|
3
|
+
*
|
|
4
|
+
* One engine, not several: countdown deadlines, scheduled menu items, scheduled
|
|
5
|
+
* footer links and any future scheduled content all resolve a wall-clock
|
|
6
|
+
* `YYYY-MM-DDTHH:mm` in an IANA zone to a single absolute instant through the
|
|
7
|
+
* functions here. Only `Intl` is used, so there is no date library to ship.
|
|
8
|
+
*
|
|
9
|
+
* Storage rule: absolute instants are stored as UTC ISO strings. A wall-clock
|
|
10
|
+
* string is only ever an *input format* — it is resolved against a zone the
|
|
11
|
+
* moment it is saved, and rendered back into a zone when an editor sees it.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Zone used when a site has not configured one. */
|
|
15
|
+
export const DEFAULT_TIMEZONE = "Europe/Stockholm";
|
|
16
|
+
|
|
17
|
+
/** A short list of zones offered in editors; any IANA zone can be typed. */
|
|
18
|
+
export const TIMEZONE_OPTIONS = [
|
|
19
|
+
"Europe/Stockholm",
|
|
20
|
+
"Europe/Oslo",
|
|
21
|
+
"Europe/Copenhagen",
|
|
22
|
+
"Europe/Helsinki",
|
|
23
|
+
"Europe/London",
|
|
24
|
+
"Europe/Berlin",
|
|
25
|
+
"Europe/Madrid",
|
|
26
|
+
"Europe/Athens",
|
|
27
|
+
"UTC",
|
|
28
|
+
"America/New_York",
|
|
29
|
+
"America/Chicago",
|
|
30
|
+
"America/Los_Angeles",
|
|
31
|
+
"Asia/Dubai",
|
|
32
|
+
"Asia/Bangkok",
|
|
33
|
+
"Asia/Tokyo",
|
|
34
|
+
"Australia/Sydney",
|
|
35
|
+
] as const;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A usable IANA zone name.
|
|
39
|
+
*
|
|
40
|
+
* An unknown or misspelled zone falls back to UTC rather than throwing: a typo
|
|
41
|
+
* in one editor field must not take a page down.
|
|
42
|
+
*/
|
|
43
|
+
export function safeZone(tz?: string | null): string {
|
|
44
|
+
const zone = tz?.trim() || DEFAULT_TIMEZONE;
|
|
45
|
+
try {
|
|
46
|
+
new Intl.DateTimeFormat("en-GB", { timeZone: zone });
|
|
47
|
+
return zone;
|
|
48
|
+
} catch {
|
|
49
|
+
return "UTC";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** True when `tz` names a zone this runtime understands. */
|
|
54
|
+
export function isValidTimezone(tz: string): boolean {
|
|
55
|
+
if (!tz.trim()) return false;
|
|
56
|
+
try {
|
|
57
|
+
new Intl.DateTimeFormat("en-GB", { timeZone: tz.trim() });
|
|
58
|
+
return true;
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Offset (ms) of `tz` from UTC at the given instant. */
|
|
65
|
+
export function timezoneOffsetMs(instant: number, tz: string): number {
|
|
66
|
+
const zone = safeZone(tz);
|
|
67
|
+
const parts = new Intl.DateTimeFormat("en-GB", {
|
|
68
|
+
timeZone: zone,
|
|
69
|
+
hour12: false,
|
|
70
|
+
year: "numeric",
|
|
71
|
+
month: "2-digit",
|
|
72
|
+
day: "2-digit",
|
|
73
|
+
hour: "2-digit",
|
|
74
|
+
minute: "2-digit",
|
|
75
|
+
second: "2-digit",
|
|
76
|
+
}).formatToParts(new Date(instant));
|
|
77
|
+
const get = (type: string) => Number(parts.find((p) => p.type === type)?.value ?? "0");
|
|
78
|
+
const asUtc = Date.UTC(
|
|
79
|
+
get("year"),
|
|
80
|
+
get("month") - 1,
|
|
81
|
+
get("day"),
|
|
82
|
+
get("hour") % 24,
|
|
83
|
+
get("minute"),
|
|
84
|
+
get("second"),
|
|
85
|
+
);
|
|
86
|
+
return asUtc - instant;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const LOCAL_RE = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Resolve `YYYY-MM-DDTHH:mm` read as wall-clock time in `tz` to an absolute
|
|
93
|
+
* timestamp (ms). Returns `null` when the value is empty or unparsable.
|
|
94
|
+
*/
|
|
95
|
+
export function zonedLocalToInstant(local?: string | null, tz?: string | null): number | null {
|
|
96
|
+
const raw = local?.trim();
|
|
97
|
+
if (!raw) return null;
|
|
98
|
+
const m = LOCAL_RE.exec(raw);
|
|
99
|
+
if (!m) return null;
|
|
100
|
+
const [, y, mo, d, h, mi, s] = m;
|
|
101
|
+
const naive = Date.UTC(
|
|
102
|
+
Number(y),
|
|
103
|
+
Number(mo) - 1,
|
|
104
|
+
Number(d),
|
|
105
|
+
Number(h ?? "0"),
|
|
106
|
+
Number(mi ?? "0"),
|
|
107
|
+
Number(s ?? "0"),
|
|
108
|
+
);
|
|
109
|
+
const zone = safeZone(tz);
|
|
110
|
+
// Two passes settle DST boundaries (the offset itself depends on the instant).
|
|
111
|
+
let instant = naive - timezoneOffsetMs(naive, zone);
|
|
112
|
+
instant = naive - timezoneOffsetMs(instant, zone);
|
|
113
|
+
return instant;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Does `local` name a wall-clock time that actually happens in `tz`?
|
|
118
|
+
*
|
|
119
|
+
* The hour a spring-forward transition skips does not exist: in Europe/Stockholm
|
|
120
|
+
* the clocks go straight from 02:00 to 03:00 on the last Sunday in March, so
|
|
121
|
+
* "02:30" that night is not a time. `zonedLocalToInstant` still resolves it —
|
|
122
|
+
* to an instant that reads back as a *different* wall clock — which is exactly
|
|
123
|
+
* the silent hour-long shift a scheduled publication must never have. Editors
|
|
124
|
+
* check this before storing, so the editor is told instead of the schedule
|
|
125
|
+
* quietly firing at the wrong time.
|
|
126
|
+
*
|
|
127
|
+
* Ambiguous times (the hour repeated by an autumn transition) are *not*
|
|
128
|
+
* rejected: both readings are real instants, and the earlier one is chosen.
|
|
129
|
+
*/
|
|
130
|
+
export function zonedLocalExists(local?: string | null, tz?: string | null): boolean {
|
|
131
|
+
const raw = local?.trim();
|
|
132
|
+
if (!raw) return false;
|
|
133
|
+
const m = LOCAL_RE.exec(raw);
|
|
134
|
+
if (!m) return false;
|
|
135
|
+
const instant = zonedLocalToInstant(raw, tz);
|
|
136
|
+
if (instant == null) return false;
|
|
137
|
+
const [, y, mo, d, h, mi] = m;
|
|
138
|
+
return instantToZonedLocal(instant, tz) === `${y}-${mo}-${d}T${h ?? "00"}:${mi ?? "00"}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The inverse of `zonedLocalToInstant`: the wall-clock `YYYY-MM-DDTHH:mm` an
|
|
145
|
+
* instant shows in `tz`. This is what an `<input type="datetime-local">` needs.
|
|
146
|
+
*/
|
|
147
|
+
export function instantToZonedLocal(instant: number, tz?: string | null): string {
|
|
148
|
+
const zone = safeZone(tz);
|
|
149
|
+
const shifted = new Date(instant + timezoneOffsetMs(instant, zone));
|
|
150
|
+
return (
|
|
151
|
+
`${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}` +
|
|
152
|
+
`T${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Human wall-clock text for an instant in a zone, e.g. "7 Sep 2026, 16:00 CEST". */
|
|
157
|
+
export function formatInZone(instant: number, tz?: string | null, locale = "en-GB"): string {
|
|
158
|
+
const zone = safeZone(tz);
|
|
159
|
+
return new Intl.DateTimeFormat(locale, {
|
|
160
|
+
timeZone: zone,
|
|
161
|
+
day: "numeric",
|
|
162
|
+
month: "short",
|
|
163
|
+
year: "numeric",
|
|
164
|
+
hour: "2-digit",
|
|
165
|
+
minute: "2-digit",
|
|
166
|
+
hour12: false,
|
|
167
|
+
timeZoneName: "short",
|
|
168
|
+
}).format(new Date(instant));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Short zone name for an editor hint, e.g. "CEST" — falling back to the zone's
|
|
173
|
+
* own identifier when a runtime has no abbreviation for it.
|
|
174
|
+
*/
|
|
175
|
+
export function zoneAbbreviation(tz?: string | null, instant: number = Date.now()): string {
|
|
176
|
+
const zone = safeZone(tz);
|
|
177
|
+
const part = new Intl.DateTimeFormat("en-GB", { timeZone: zone, timeZoneName: "short" })
|
|
178
|
+
.formatToParts(new Date(instant))
|
|
179
|
+
.find((p) => p.type === "timeZoneName");
|
|
180
|
+
return part?.value ?? zone;
|
|
181
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One timer that re-reads a screen when its next publication boundary falls due.
|
|
3
|
+
*
|
|
4
|
+
* A builder tab or the pages dashboard is often left open across the moment a
|
|
5
|
+
* schedule takes effect. Without this the header still says "Scheduled" half an
|
|
6
|
+
* hour after the page went live, and the only way to find out is to navigate
|
|
7
|
+
* away and back.
|
|
8
|
+
*
|
|
9
|
+
* The awkward part is that the boundary is a fact about the *server's* data and
|
|
10
|
+
* the refresh is asynchronous. Between asking for fresh data and receiving it,
|
|
11
|
+
* `boundaryTimeoutMs` keeps answering zero — the boundary really has passed —
|
|
12
|
+
* so an effect that re-arms on its own inputs re-arms instantly, refreshes
|
|
13
|
+
* again, and does it once more, for as long as the answer takes to come back or
|
|
14
|
+
* forever if it never does. That is why the timer here is a chain of `setTimeout`
|
|
15
|
+
* calls owned by one effect rather than a dependency on a counter: after a
|
|
16
|
+
* refresh has been asked for, the next attempt is a bounded, growing wait, and
|
|
17
|
+
* the boundary that actually re-arms the timer is a *new* one arriving in fresh
|
|
18
|
+
* data.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { useEffect, useRef } from "react";
|
|
22
|
+
|
|
23
|
+
import { boundaryRetryMs, boundaryTimeoutMs } from "./page-publication";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Call `refresh` when `boundary` arrives, and again if it is still in the past.
|
|
27
|
+
*
|
|
28
|
+
* `boundary` is the next transition instant — `publication_at` from the server,
|
|
29
|
+
* as an ISO string or epoch milliseconds. Null or unparseable means nothing is
|
|
30
|
+
* scheduled and no timer is set at all.
|
|
31
|
+
*
|
|
32
|
+
* `refresh` is read through a ref, so a parent handing down a new callback on
|
|
33
|
+
* every render does not restart the wait; only the boundary itself does.
|
|
34
|
+
*/
|
|
35
|
+
export function usePublicationBoundaryRefresh(
|
|
36
|
+
boundary: string | number | null | undefined,
|
|
37
|
+
refresh: () => void,
|
|
38
|
+
): void {
|
|
39
|
+
const refreshRef = useRef(refresh);
|
|
40
|
+
refreshRef.current = refresh;
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const first = boundaryTimeoutMs(boundary);
|
|
44
|
+
if (first === null) return;
|
|
45
|
+
|
|
46
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
47
|
+
// Counted per boundary: a new instant arrives as a new effect, with this
|
|
48
|
+
// back at zero, so one slow refresh cannot lengthen the next one's waits.
|
|
49
|
+
let asked = 0;
|
|
50
|
+
|
|
51
|
+
const arm = (ms: number) => {
|
|
52
|
+
timer = setTimeout(fire, ms);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function fire() {
|
|
56
|
+
try {
|
|
57
|
+
refreshRef.current();
|
|
58
|
+
} catch {
|
|
59
|
+
// The failure this backoff exists for. Dropping the chain here would
|
|
60
|
+
// leave the screen stale until somebody navigated.
|
|
61
|
+
}
|
|
62
|
+
// Re-read the clock rather than assuming the wait that just elapsed is
|
|
63
|
+
// the whole of it: a laptop that slept through the boundary wakes with a
|
|
64
|
+
// timer measured against a clock that has moved on.
|
|
65
|
+
const remaining = boundaryTimeoutMs(boundary);
|
|
66
|
+
if (remaining === null) return;
|
|
67
|
+
if (remaining > 0) {
|
|
68
|
+
// The six-hour cap fired, not the boundary. Nothing has happened yet,
|
|
69
|
+
// so this is not an attempt at anything and the wait starts over.
|
|
70
|
+
asked = 0;
|
|
71
|
+
arm(remaining);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
// The boundary itself. Fresh data has not arrived — it cannot have, the
|
|
75
|
+
// request went out a moment ago — so the zero this instant computes to is
|
|
76
|
+
// not a delay to re-arm on.
|
|
77
|
+
asked += 1;
|
|
78
|
+
arm(boundaryRetryMs(asked));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
arm(first);
|
|
82
|
+
return () => clearTimeout(timer);
|
|
83
|
+
}, [boundary]);
|
|
84
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A clock that ticks only when a schedule actually changes.
|
|
3
|
+
*
|
|
4
|
+
* Scheduled menu and footer entries are evaluated against `now`. A page left
|
|
5
|
+
* open across a publish time would otherwise keep rendering the state it was
|
|
6
|
+
* rendered with — a stale schedule is exactly what requirement-level caching
|
|
7
|
+
* must not do. Rather than polling every second, this hook computes the next
|
|
8
|
+
* boundary from the schedules themselves and sets a single timer for it, so a
|
|
9
|
+
* page with no pending change costs nothing at all.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useEffect, useState } from "react";
|
|
13
|
+
|
|
14
|
+
import { nextScheduleChangeAcross, type VisibilitySchedule } from "./visibility-schedule";
|
|
15
|
+
|
|
16
|
+
/** Longest single timeout we ever set; `setTimeout` overflows past ~24.8 days. */
|
|
17
|
+
const MAX_DELAY = 21_600_000; // 6 hours
|
|
18
|
+
|
|
19
|
+
export function useScheduleClock(
|
|
20
|
+
schedules: readonly (VisibilitySchedule | null | undefined)[],
|
|
21
|
+
): number {
|
|
22
|
+
const [now, setNow] = useState(() => Date.now());
|
|
23
|
+
|
|
24
|
+
// Boundaries, as a stable primitive, so the effect re-runs when the set of
|
|
25
|
+
// pending changes actually changes rather than on every render.
|
|
26
|
+
const boundaries = schedules
|
|
27
|
+
.map((schedule) => schedule && `${schedule.enabled}|${schedule.publishAt}|${schedule.unpublishAt}`)
|
|
28
|
+
.filter(Boolean)
|
|
29
|
+
.join(",");
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const next = nextScheduleChangeAcross(schedules, Date.now());
|
|
33
|
+
if (next == null) return;
|
|
34
|
+
// Clamp: a far-future boundary is re-checked periodically instead of
|
|
35
|
+
// trusting one enormous timer to survive.
|
|
36
|
+
const delay = Math.min(Math.max(next - Date.now(), 0) + 250, MAX_DELAY);
|
|
37
|
+
const timer = setTimeout(() => setNow(Date.now()), delay);
|
|
38
|
+
return () => clearTimeout(timer);
|
|
39
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
40
|
+
}, [boundaries, now]);
|
|
41
|
+
|
|
42
|
+
return now;
|
|
43
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The platform's one scheduled-visibility model.
|
|
3
|
+
*
|
|
4
|
+
* Menu items, footer links and anything else that should appear or disappear on
|
|
5
|
+
* a date share this shape and this evaluator. There is deliberately no second
|
|
6
|
+
* scheduling engine: date parsing, timezone conversion, evaluation and
|
|
7
|
+
* validation all live here, on top of `./timezone`.
|
|
8
|
+
*
|
|
9
|
+
* Instants are stored as UTC ISO strings (`2026-09-10T16:00:00.000Z`). Editors
|
|
10
|
+
* type wall-clock time in the site's timezone; `localInputToIso` /
|
|
11
|
+
* `isoToLocalInput` are the only sanctioned conversions between the two.
|
|
12
|
+
*
|
|
13
|
+
* Boundary semantics, fixed and tested:
|
|
14
|
+
* now < publishAt hidden
|
|
15
|
+
* now === publishAt visible
|
|
16
|
+
* between visible
|
|
17
|
+
* now === unpublishAt hidden
|
|
18
|
+
* now > unpublishAt hidden
|
|
19
|
+
* An empty window (`unpublishAt <= publishAt`) can never be open, so it
|
|
20
|
+
* evaluates to hidden and is reported as a validation problem in the editor.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { formatInZone, instantToZonedLocal, zonedLocalToInstant } from "./timezone";
|
|
24
|
+
|
|
25
|
+
export type VisibilitySchedule = {
|
|
26
|
+
/**
|
|
27
|
+
* Master switch. `false` hides the item outright, whatever the dates say;
|
|
28
|
+
* `undefined` means visible, so content that predates scheduling is unchanged.
|
|
29
|
+
*/
|
|
30
|
+
enabled?: boolean;
|
|
31
|
+
/** UTC ISO instant the item becomes visible. Empty means "already live". */
|
|
32
|
+
publishAt?: string | null;
|
|
33
|
+
/** UTC ISO instant the item stops being visible. Empty means "no end". */
|
|
34
|
+
unpublishAt?: string | null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** A schedule that changes nothing — the value a new row starts from. */
|
|
38
|
+
export const EMPTY_VISIBILITY_SCHEDULE: VisibilitySchedule = { enabled: true };
|
|
39
|
+
|
|
40
|
+
/** Anything carrying an optional schedule, e.g. a menu item or footer link. */
|
|
41
|
+
export type Schedulable = { schedule?: VisibilitySchedule | null };
|
|
42
|
+
|
|
43
|
+
export type VisibilityState =
|
|
44
|
+
| "visible"
|
|
45
|
+
| "hidden"
|
|
46
|
+
| "scheduled-to-publish"
|
|
47
|
+
| "scheduled-to-hide"
|
|
48
|
+
| "expired"
|
|
49
|
+
| "invalid";
|
|
50
|
+
|
|
51
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
52
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Parse an ISO string / epoch ms / Date into epoch ms. `null` when unusable. */
|
|
56
|
+
export function toInstant(value: string | number | Date | null | undefined): number | null {
|
|
57
|
+
if (value == null || value === "") return null;
|
|
58
|
+
if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.getTime() : null;
|
|
59
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
60
|
+
const trimmed = value.trim();
|
|
61
|
+
if (!trimmed) return null;
|
|
62
|
+
const parsed = Date.parse(trimmed);
|
|
63
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Narrow untrusted JSON (a content-section row) to a schedule. */
|
|
67
|
+
export function normalizeSchedule(raw: unknown): VisibilitySchedule | undefined {
|
|
68
|
+
if (!isRecord(raw)) return undefined;
|
|
69
|
+
const out: VisibilitySchedule = {};
|
|
70
|
+
if (typeof raw["enabled"] === "boolean") out.enabled = raw["enabled"];
|
|
71
|
+
for (const key of ["publishAt", "unpublishAt"] as const) {
|
|
72
|
+
const value = raw[key];
|
|
73
|
+
if (typeof value === "string" && value.trim()) out[key] = value.trim();
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** True when a schedule carries no instruction at all. */
|
|
79
|
+
export function isEmptySchedule(schedule: VisibilitySchedule | null | undefined): boolean {
|
|
80
|
+
if (!schedule) return true;
|
|
81
|
+
return (
|
|
82
|
+
schedule.enabled !== false &&
|
|
83
|
+
toInstant(schedule.publishAt) == null &&
|
|
84
|
+
toInstant(schedule.unpublishAt) == null
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Should this item be shown at `now`?
|
|
90
|
+
*
|
|
91
|
+
* `now` is always passed in (epoch ms or Date) so rendering is a pure function
|
|
92
|
+
* of its inputs and every boundary is testable.
|
|
93
|
+
*/
|
|
94
|
+
export function isVisibleAt(
|
|
95
|
+
schedule: VisibilitySchedule | null | undefined,
|
|
96
|
+
now: number | Date,
|
|
97
|
+
): boolean {
|
|
98
|
+
const state = visibilityStateAt(schedule, now);
|
|
99
|
+
return state === "visible" || state === "scheduled-to-hide";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The state a schedule is in at `now`. `visible` and `scheduled-to-hide` are
|
|
104
|
+
* both live states — the second one just also has an end date in the future.
|
|
105
|
+
*/
|
|
106
|
+
export function visibilityStateAt(
|
|
107
|
+
schedule: VisibilitySchedule | null | undefined,
|
|
108
|
+
now: number | Date,
|
|
109
|
+
): VisibilityState {
|
|
110
|
+
if (!schedule) return "visible";
|
|
111
|
+
if (schedule.enabled === false) return "hidden";
|
|
112
|
+
|
|
113
|
+
const at = toInstant(now) ?? Date.now();
|
|
114
|
+
const from = toInstant(schedule.publishAt);
|
|
115
|
+
const until = toInstant(schedule.unpublishAt);
|
|
116
|
+
|
|
117
|
+
// A window that closes before it opens can never be live. Fail closed and
|
|
118
|
+
// let the editor show the problem rather than guessing which date was meant.
|
|
119
|
+
if (from != null && until != null && until <= from) return "invalid";
|
|
120
|
+
|
|
121
|
+
if (from != null && at < from) return "scheduled-to-publish";
|
|
122
|
+
if (until != null && at >= until) return "expired";
|
|
123
|
+
if (until != null) return "scheduled-to-hide";
|
|
124
|
+
return "visible";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Badge text for a state. Never colour-only: the label always says the state. */
|
|
128
|
+
export const VISIBILITY_STATE_LABELS: Record<VisibilityState, string> = {
|
|
129
|
+
visible: "Visible",
|
|
130
|
+
hidden: "Hidden",
|
|
131
|
+
"scheduled-to-publish": "Scheduled to publish",
|
|
132
|
+
"scheduled-to-hide": "Scheduled to hide",
|
|
133
|
+
expired: "Hidden",
|
|
134
|
+
invalid: "Invalid schedule",
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/** Longer wording for tooltips and screen-reader descriptions. */
|
|
138
|
+
export const VISIBILITY_STATE_DESCRIPTIONS: Record<VisibilityState, string> = {
|
|
139
|
+
visible: "Shown on the public website.",
|
|
140
|
+
hidden: "Hidden from the public website.",
|
|
141
|
+
"scheduled-to-publish": "Hidden until the start date.",
|
|
142
|
+
"scheduled-to-hide": "Shown now, hidden again on the end date.",
|
|
143
|
+
expired: "Hidden — the end date has passed.",
|
|
144
|
+
invalid: "The end date is not after the start date, so this never shows.",
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The next instant at which `isVisibleAt` would return a different answer, or
|
|
149
|
+
* `null` when the schedule never changes again. Callers use it to cap cache
|
|
150
|
+
* lifetimes so a scheduled change is never hidden behind a stale entry.
|
|
151
|
+
*/
|
|
152
|
+
export function nextVisibilityChangeAt(
|
|
153
|
+
schedule: VisibilitySchedule | null | undefined,
|
|
154
|
+
now: number | Date,
|
|
155
|
+
): number | null {
|
|
156
|
+
if (!schedule || schedule.enabled === false) return null;
|
|
157
|
+
const at = toInstant(now) ?? Date.now();
|
|
158
|
+
const from = toInstant(schedule.publishAt);
|
|
159
|
+
const until = toInstant(schedule.unpublishAt);
|
|
160
|
+
if (from != null && until != null && until <= from) return null;
|
|
161
|
+
const upcoming = [from, until].filter((t): t is number => t != null && t > at);
|
|
162
|
+
return upcoming.length ? Math.min(...upcoming) : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** The soonest scheduled change across many items; `null` when none is pending. */
|
|
166
|
+
export function nextScheduleChangeAcross(
|
|
167
|
+
schedules: Iterable<VisibilitySchedule | null | undefined>,
|
|
168
|
+
now: number | Date,
|
|
169
|
+
): number | null {
|
|
170
|
+
let soonest: number | null = null;
|
|
171
|
+
for (const schedule of schedules) {
|
|
172
|
+
const next = nextVisibilityChangeAt(schedule, now);
|
|
173
|
+
if (next != null && (soonest == null || next < soonest)) soonest = next;
|
|
174
|
+
}
|
|
175
|
+
return soonest;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Human-readable validation problems. An empty array means the schedule is
|
|
180
|
+
* safe to save; the editor blocks saving while anything is listed.
|
|
181
|
+
*/
|
|
182
|
+
export function scheduleProblems(schedule: VisibilitySchedule | null | undefined): string[] {
|
|
183
|
+
if (!schedule) return [];
|
|
184
|
+
const problems: string[] = [];
|
|
185
|
+
const rawFrom = schedule.publishAt?.trim();
|
|
186
|
+
const rawUntil = schedule.unpublishAt?.trim();
|
|
187
|
+
const from = toInstant(rawFrom);
|
|
188
|
+
const until = toInstant(rawUntil);
|
|
189
|
+
if (rawFrom && from == null) problems.push("Start date is not a valid date and time.");
|
|
190
|
+
if (rawUntil && until == null) problems.push("End date is not a valid date and time.");
|
|
191
|
+
if (from != null && until != null && until <= from) {
|
|
192
|
+
problems.push("End date must be after the start date.");
|
|
193
|
+
}
|
|
194
|
+
return problems;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A schedule problem, addressed by where in a content document it was found. */
|
|
198
|
+
export type ScheduleProblemAt = { path: string; problem: string };
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Every schedule problem anywhere in a content document.
|
|
202
|
+
*
|
|
203
|
+
* Content records are free-form JSON — a navigation tree, footer columns,
|
|
204
|
+
* shared blocks — so there is no fixed field list to validate. Any object under
|
|
205
|
+
* a `schedule` key is a schedule, wherever it sits, and each one is checked
|
|
206
|
+
* with the same {@link scheduleProblems} the editor shows. `path` is the dotted
|
|
207
|
+
* location, so a refusal can say which item is wrong instead of just "invalid".
|
|
208
|
+
*/
|
|
209
|
+
export function scheduleProblemsIn(value: unknown, path = ""): ScheduleProblemAt[] {
|
|
210
|
+
const found: ScheduleProblemAt[] = [];
|
|
211
|
+
const visit = (node: unknown, at: string) => {
|
|
212
|
+
if (Array.isArray(node)) {
|
|
213
|
+
node.forEach((entry, i) => visit(entry, `${at}[${i}]`));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (!isRecord(node)) return;
|
|
217
|
+
for (const [key, child] of Object.entries(node)) {
|
|
218
|
+
const here = at ? `${at}.${key}` : key;
|
|
219
|
+
if (key === "schedule" && isRecord(child)) {
|
|
220
|
+
for (const problem of scheduleProblems(normalizeSchedule(child))) {
|
|
221
|
+
found.push({ path: here, problem });
|
|
222
|
+
}
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
visit(child, here);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
visit(value, path);
|
|
229
|
+
return found;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Convert a `datetime-local` value typed in `tz` into the stored UTC ISO. */
|
|
233
|
+
export function localInputToIso(local: string | null | undefined, tz?: string | null): string | null {
|
|
234
|
+
const instant = zonedLocalToInstant(local, tz);
|
|
235
|
+
return instant == null ? null : new Date(instant).toISOString();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Convert a stored UTC ISO into the `datetime-local` value for `tz`. */
|
|
239
|
+
export function isoToLocalInput(iso: string | null | undefined, tz?: string | null): string {
|
|
240
|
+
const instant = toInstant(iso);
|
|
241
|
+
return instant == null ? "" : instantToZonedLocal(instant, tz);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** One-line summary of a schedule, in the site's timezone. */
|
|
245
|
+
export function describeSchedule(
|
|
246
|
+
schedule: VisibilitySchedule | null | undefined,
|
|
247
|
+
tz?: string | null,
|
|
248
|
+
locale = "en-GB",
|
|
249
|
+
): string {
|
|
250
|
+
if (!schedule || schedule.enabled === false) return "Hidden";
|
|
251
|
+
const from = toInstant(schedule.publishAt);
|
|
252
|
+
const until = toInstant(schedule.unpublishAt);
|
|
253
|
+
if (from == null && until == null) return "Always visible";
|
|
254
|
+
if (from != null && until != null) {
|
|
255
|
+
return `Visible ${formatInZone(from, tz, locale)} – ${formatInZone(until, tz, locale)}`;
|
|
256
|
+
}
|
|
257
|
+
if (from != null) return `Visible from ${formatInZone(from, tz, locale)}`;
|
|
258
|
+
return `Visible until ${formatInZone(until as number, tz, locale)}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Filter any schedulable list down to what a visitor may see at `now`. */
|
|
262
|
+
export function visibleItems<T extends Schedulable>(
|
|
263
|
+
items: readonly T[] | undefined,
|
|
264
|
+
now: number | Date,
|
|
265
|
+
): T[] {
|
|
266
|
+
return (items ?? []).filter((item) => isVisibleAt(item.schedule, now));
|
|
267
|
+
}
|
package/src/config/modules.ts
CHANGED
|
@@ -18,7 +18,6 @@ import {
|
|
|
18
18
|
Blocks,
|
|
19
19
|
Calendar,
|
|
20
20
|
ClipboardList,
|
|
21
|
-
Globe,
|
|
22
21
|
GraduationCap,
|
|
23
22
|
MessageSquare,
|
|
24
23
|
MessageSquareQuote,
|
|
@@ -71,15 +70,20 @@ export type CmsModuleDefinition = {
|
|
|
71
70
|
/** One line of help on the Modules screen. */
|
|
72
71
|
readonly description?: string;
|
|
73
72
|
/**
|
|
74
|
-
* Sidebar contribution. Absent for modules
|
|
75
|
-
* `email`, `tracking
|
|
73
|
+
* Sidebar contribution. Absent for modules whose screen belongs under Admin
|
|
74
|
+
* rather than in the sidebar — `email`, `tracking`, `backup` and `domains`.
|
|
76
75
|
*/
|
|
77
76
|
readonly nav?: CmsModuleNav;
|
|
78
77
|
/**
|
|
79
|
-
* Minimum role for the module's own screens.
|
|
80
|
-
*
|
|
78
|
+
* Minimum role for the module's own screens.
|
|
79
|
+
*
|
|
80
|
+
* This is not decoration: it has to name the same role the module's server
|
|
81
|
+
* functions actually demand. `admin` is used by modules whose writes call
|
|
82
|
+
* `has_role(user, 'admin')`, and `superadmin` by modules that can destroy or
|
|
83
|
+
* exfiltrate data. A module advertised to staff whose saves are refused sends
|
|
84
|
+
* an editor to a screen that cannot work.
|
|
81
85
|
*/
|
|
82
|
-
readonly requiresRole?: "staff" | "superadmin";
|
|
86
|
+
readonly requiresRole?: "staff" | "admin" | "superadmin";
|
|
83
87
|
/**
|
|
84
88
|
* True when the module contributes a component to the public site. The public
|
|
85
89
|
* runtime host renders only the modules that both declare this and are
|
|
@@ -118,14 +122,13 @@ export const CMS_MODULES = [
|
|
|
118
122
|
requiresRole: "staff",
|
|
119
123
|
},
|
|
120
124
|
{ key: "email", label: "Transactional email", defaultEnabled: true, requiresRole: "staff" },
|
|
121
|
-
{ key: "tracking", label: "Code & tracking", defaultEnabled: true, requiresRole: "
|
|
125
|
+
{ key: "tracking", label: "Code & tracking", defaultEnabled: true, requiresRole: "admin" },
|
|
122
126
|
{ key: "backup", label: "Backup & restore", defaultEnabled: true, requiresRole: "superadmin" },
|
|
123
127
|
{
|
|
124
128
|
key: "domains",
|
|
125
|
-
label: "
|
|
129
|
+
label: "Languages & domains",
|
|
126
130
|
defaultEnabled: false,
|
|
127
|
-
|
|
128
|
-
requiresRole: "staff",
|
|
131
|
+
requiresRole: "admin",
|
|
129
132
|
},
|
|
130
133
|
{
|
|
131
134
|
key: "forms",
|
|
@@ -166,6 +169,27 @@ export function findCmsModule(key: CmsModuleKey): CmsModuleDefinition | undefine
|
|
|
166
169
|
return CMS_MODULES.find((module) => module.key === key);
|
|
167
170
|
}
|
|
168
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Whether a viewer's platform roles satisfy a module's `requiresRole`.
|
|
174
|
+
*
|
|
175
|
+
* `staff` is not a row in `user_roles`: it is the host's own `is_staff` check,
|
|
176
|
+
* which anyone rendering the backoffice has already passed, so it is satisfied
|
|
177
|
+
* here. `admin` and `superadmin` are rows, and each has to be held in its own
|
|
178
|
+
* right — the server asks `has_role(user, 'admin')`, and that question is not
|
|
179
|
+
* answered by holding some other, higher-sounding role. Inferring one from the
|
|
180
|
+
* other here would put an editable screen in front of someone whose every save
|
|
181
|
+
* the server then refuses.
|
|
182
|
+
*/
|
|
183
|
+
export function moduleRoleSatisfied(
|
|
184
|
+
requiresRole: CmsModuleDefinition["requiresRole"],
|
|
185
|
+
roles: readonly string[],
|
|
186
|
+
): boolean {
|
|
187
|
+
if (requiresRole === "admin" || requiresRole === "superadmin") {
|
|
188
|
+
return roles.includes(requiresRole);
|
|
189
|
+
}
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
|
|
169
193
|
/** The defaults a site starts from, derived so the two can never disagree. */
|
|
170
194
|
export const DEFAULT_FEATURE_FLAGS: SiteFeatureFlags = Object.fromEntries(
|
|
171
195
|
CMS_MODULES.map((module) => [module.key, module.defaultEnabled]),
|