@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,289 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useId,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { Pause, Play } from "lucide-react";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A phone-sized horizontal carousel that becomes an ordinary grid from the
|
|
13
|
+
* `sm` breakpoint up.
|
|
14
|
+
*
|
|
15
|
+
* The scrolling is the browser's own: a scroll-snapping overflow container
|
|
16
|
+
* with momentum, so a swipe feels native and nothing is simulated in
|
|
17
|
+
* JavaScript. The script here only tracks which slide is centred, moves the
|
|
18
|
+
* container when a dot is clicked, and optionally advances on a timer — which
|
|
19
|
+
* is why this needs no carousel dependency.
|
|
20
|
+
*
|
|
21
|
+
* Above `sm` the same markup is a grid: the desktop and tablet layouts a block
|
|
22
|
+
* already had are untouched, the indicator and the autoplay controls are
|
|
23
|
+
* hidden, and no timer runs.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Sensible bounds for an author-set autoplay interval. */
|
|
27
|
+
export const MIN_AUTOPLAY_MS = 2000;
|
|
28
|
+
export const MAX_AUTOPLAY_MS = 30000;
|
|
29
|
+
export const DEFAULT_AUTOPLAY_MS = 5000;
|
|
30
|
+
|
|
31
|
+
export function clampAutoplayMs(value: unknown): number {
|
|
32
|
+
const n = Number(value);
|
|
33
|
+
if (!Number.isFinite(n) || n <= 0) return DEFAULT_AUTOPLAY_MS;
|
|
34
|
+
return Math.min(MAX_AUTOPLAY_MS, Math.max(MIN_AUTOPLAY_MS, Math.round(n)));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function prefersReducedMotion(): boolean {
|
|
38
|
+
return (
|
|
39
|
+
typeof window !== "undefined" &&
|
|
40
|
+
typeof window.matchMedia === "function" &&
|
|
41
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Slides are found by attribute, never by child index: the track also holds the
|
|
47
|
+
* element that names it, and counting that as a slide would leave the indicator
|
|
48
|
+
* pointing one card ahead of the one the reader is actually looking at.
|
|
49
|
+
*/
|
|
50
|
+
const SLIDE_SELECTOR = ":scope > [data-cms-slide]";
|
|
51
|
+
|
|
52
|
+
function slideElements(track: HTMLElement): HTMLElement[] {
|
|
53
|
+
return Array.from(track.querySelectorAll<HTMLElement>(SLIDE_SELECTOR));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Which slide currently sits nearest the middle of the visible track. */
|
|
57
|
+
function centredSlide(track: HTMLElement): number {
|
|
58
|
+
const mid = track.scrollLeft + track.clientWidth / 2;
|
|
59
|
+
let best = 0;
|
|
60
|
+
let bestDistance = Infinity;
|
|
61
|
+
slideElements(track).forEach((el, i) => {
|
|
62
|
+
const distance = Math.abs(el.offsetLeft + el.clientWidth / 2 - mid);
|
|
63
|
+
if (distance < bestDistance) {
|
|
64
|
+
bestDistance = distance;
|
|
65
|
+
best = i;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
return best;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type MobileCarouselProps = {
|
|
72
|
+
/** One node per slide. Order is the reading order. */
|
|
73
|
+
children: ReactNode[];
|
|
74
|
+
/** Accessible name for the carousel as a whole. */
|
|
75
|
+
label?: string;
|
|
76
|
+
/** Grid classes applied from `sm` up, e.g. `sm:grid-cols-2 lg:grid-cols-3`. */
|
|
77
|
+
gridClassName?: string;
|
|
78
|
+
/** Gap utilities shared by both layouts. */
|
|
79
|
+
gapClassName?: string;
|
|
80
|
+
/** Extra classes for each slide. */
|
|
81
|
+
slideClassName?: string;
|
|
82
|
+
/** Advance on a timer while the carousel is on screen. */
|
|
83
|
+
autoplay?: boolean;
|
|
84
|
+
autoplayMs?: number;
|
|
85
|
+
/** Slide width on phones, as a CSS width. */
|
|
86
|
+
slideWidth?: string;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export function MobileCarousel({
|
|
90
|
+
children,
|
|
91
|
+
label = "Carousel",
|
|
92
|
+
gridClassName = "sm:grid-cols-2",
|
|
93
|
+
gapClassName = "gap-6",
|
|
94
|
+
slideClassName = "",
|
|
95
|
+
autoplay = false,
|
|
96
|
+
autoplayMs = DEFAULT_AUTOPLAY_MS,
|
|
97
|
+
slideWidth = "85%",
|
|
98
|
+
}: MobileCarouselProps) {
|
|
99
|
+
const slides = children;
|
|
100
|
+
const count = slides.length;
|
|
101
|
+
const trackRef = useRef<HTMLDivElement>(null);
|
|
102
|
+
const [index, setIndex] = useState(0);
|
|
103
|
+
const [userPaused, setUserPaused] = useState(false);
|
|
104
|
+
/** Bumped by any interaction, which restarts the autoplay timer. */
|
|
105
|
+
const [interaction, setInteraction] = useState(0);
|
|
106
|
+
const [onScreen, setOnScreen] = useState(false);
|
|
107
|
+
const [reduced, setReduced] = useState(false);
|
|
108
|
+
const labelId = useId();
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
setReduced(prefersReducedMotion());
|
|
112
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
|
|
113
|
+
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
114
|
+
const onChange = () => setReduced(mq.matches);
|
|
115
|
+
mq.addEventListener("change", onChange);
|
|
116
|
+
return () => mq.removeEventListener("change", onChange);
|
|
117
|
+
}, []);
|
|
118
|
+
|
|
119
|
+
/* Which slide is centred. Read from the scroll position, so a swipe, a dot
|
|
120
|
+
click and the timer all end up agreeing. */
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
const track = trackRef.current;
|
|
123
|
+
if (!track) return;
|
|
124
|
+
let frame = 0;
|
|
125
|
+
const measure = () => {
|
|
126
|
+
frame = 0;
|
|
127
|
+
setIndex(centredSlide(track));
|
|
128
|
+
};
|
|
129
|
+
const onScroll = () => {
|
|
130
|
+
if (frame) return;
|
|
131
|
+
frame = requestAnimationFrame(measure);
|
|
132
|
+
};
|
|
133
|
+
track.addEventListener("scroll", onScroll, { passive: true });
|
|
134
|
+
measure();
|
|
135
|
+
return () => {
|
|
136
|
+
track.removeEventListener("scroll", onScroll);
|
|
137
|
+
if (frame) cancelAnimationFrame(frame);
|
|
138
|
+
};
|
|
139
|
+
}, [count]);
|
|
140
|
+
|
|
141
|
+
/* Autoplay only matters while the carousel is actually on screen. */
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
const track = trackRef.current;
|
|
144
|
+
if (!track || typeof IntersectionObserver === "undefined") {
|
|
145
|
+
setOnScreen(true);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const io = new IntersectionObserver(
|
|
149
|
+
(entries) => setOnScreen(entries.some((e) => e.isIntersecting)),
|
|
150
|
+
{ threshold: 0.35 },
|
|
151
|
+
);
|
|
152
|
+
io.observe(track);
|
|
153
|
+
return () => io.disconnect();
|
|
154
|
+
}, []);
|
|
155
|
+
|
|
156
|
+
const goTo = useCallback(
|
|
157
|
+
(next: number, smooth = true) => {
|
|
158
|
+
const track = trackRef.current;
|
|
159
|
+
if (!track || count === 0) return;
|
|
160
|
+
const target = ((next % count) + count) % count;
|
|
161
|
+
const el = slideElements(track)[target];
|
|
162
|
+
if (!el) return;
|
|
163
|
+
const left = el.offsetLeft - (track.clientWidth - el.clientWidth) / 2;
|
|
164
|
+
track.scrollTo({ left, behavior: smooth && !reduced ? "smooth" : "auto" });
|
|
165
|
+
setIndex(target);
|
|
166
|
+
},
|
|
167
|
+
[count, reduced],
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
/* One timer, restarted whenever the reader touches anything. Paused for
|
|
171
|
+
reduced motion, for a hidden tab, off screen, or on request. */
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
if (!autoplay || reduced || userPaused || !onScreen || count < 2) return;
|
|
174
|
+
if (typeof window === "undefined") return;
|
|
175
|
+
// Only phones show the carousel; above `sm` the grid needs no timer.
|
|
176
|
+
if (typeof window.matchMedia === "function" && window.matchMedia("(min-width: 640px)").matches) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const ms = clampAutoplayMs(autoplayMs);
|
|
180
|
+
const id = window.setInterval(() => {
|
|
181
|
+
if (typeof document !== "undefined" && document.hidden) return;
|
|
182
|
+
const track = trackRef.current;
|
|
183
|
+
if (!track) return;
|
|
184
|
+
goTo(centredSlide(track) + 1);
|
|
185
|
+
}, ms);
|
|
186
|
+
return () => window.clearInterval(id);
|
|
187
|
+
}, [autoplay, autoplayMs, reduced, userPaused, onScreen, count, interaction, goTo]);
|
|
188
|
+
|
|
189
|
+
const noteInteraction = useCallback(() => setInteraction((n) => n + 1), []);
|
|
190
|
+
|
|
191
|
+
if (count === 0) return null;
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<div
|
|
195
|
+
role="group"
|
|
196
|
+
aria-roledescription="carousel"
|
|
197
|
+
aria-label={label}
|
|
198
|
+
className="relative"
|
|
199
|
+
onPointerDown={noteInteraction}
|
|
200
|
+
onTouchStart={noteInteraction}
|
|
201
|
+
>
|
|
202
|
+
<div
|
|
203
|
+
ref={trackRef}
|
|
204
|
+
tabIndex={0}
|
|
205
|
+
aria-labelledby={labelId}
|
|
206
|
+
onKeyDown={(e) => {
|
|
207
|
+
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
|
|
208
|
+
e.preventDefault();
|
|
209
|
+
noteInteraction();
|
|
210
|
+
goTo(index + (e.key === "ArrowRight" ? 1 : -1));
|
|
211
|
+
}}
|
|
212
|
+
className={[
|
|
213
|
+
// Phones: a snapping scroller. The negative margin lets slides bleed
|
|
214
|
+
// to the screen edge without the page itself scrolling sideways.
|
|
215
|
+
"relative -mx-6 flex snap-x snap-mandatory overflow-x-auto px-6",
|
|
216
|
+
// `motion-safe:` and not plain `scroll-smooth`: a stylesheet's
|
|
217
|
+
// scroll-behavior overrides `scrollTo({ behavior: "auto" })`, so the
|
|
218
|
+
// reduced-motion preference has to be honoured in the CSS as well.
|
|
219
|
+
"motion-safe:scroll-smooth",
|
|
220
|
+
"[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
|
221
|
+
"focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sea",
|
|
222
|
+
// From `sm` up it is the grid the block always had.
|
|
223
|
+
"sm:mx-0 sm:grid sm:snap-none sm:overflow-visible sm:px-0",
|
|
224
|
+
gapClassName,
|
|
225
|
+
gridClassName,
|
|
226
|
+
].join(" ")}
|
|
227
|
+
>
|
|
228
|
+
<span id={labelId} className="sr-only">
|
|
229
|
+
{label}
|
|
230
|
+
</span>
|
|
231
|
+
{slides.map((slide, i) => (
|
|
232
|
+
<div
|
|
233
|
+
key={i}
|
|
234
|
+
role="group"
|
|
235
|
+
aria-roledescription="slide"
|
|
236
|
+
aria-label={`${i + 1} of ${count}`}
|
|
237
|
+
data-cms-slide=""
|
|
238
|
+
style={{ ["--cms-slide-w" as string]: slideWidth }}
|
|
239
|
+
className={`w-[var(--cms-slide-w)] shrink-0 snap-center sm:w-auto sm:shrink ${slideClassName}`}
|
|
240
|
+
>
|
|
241
|
+
{slide}
|
|
242
|
+
</div>
|
|
243
|
+
))}
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
{count > 1 ? (
|
|
247
|
+
<div className="mt-6 flex items-center justify-center gap-3 sm:hidden">
|
|
248
|
+
<div className="flex items-center gap-2">
|
|
249
|
+
{slides.map((_, i) => (
|
|
250
|
+
<button
|
|
251
|
+
key={i}
|
|
252
|
+
type="button"
|
|
253
|
+
aria-label={`Go to slide ${i + 1} of ${count}`}
|
|
254
|
+
aria-current={i === index ? "true" : undefined}
|
|
255
|
+
onClick={() => {
|
|
256
|
+
noteInteraction();
|
|
257
|
+
goTo(i);
|
|
258
|
+
}}
|
|
259
|
+
className={`size-2.5 rounded-full border transition-colors ${
|
|
260
|
+
i === index ? "border-sea bg-sea" : "border-ink/25 bg-ink/15"
|
|
261
|
+
}`}
|
|
262
|
+
/>
|
|
263
|
+
))}
|
|
264
|
+
</div>
|
|
265
|
+
{autoplay && !reduced ? (
|
|
266
|
+
<button
|
|
267
|
+
type="button"
|
|
268
|
+
onClick={() => {
|
|
269
|
+
noteInteraction();
|
|
270
|
+
setUserPaused((p) => !p);
|
|
271
|
+
}}
|
|
272
|
+
aria-pressed={userPaused}
|
|
273
|
+
className="ml-1 inline-flex size-7 items-center justify-center rounded-full border border-ink/20 text-ink/60 hover:border-sea hover:text-ink"
|
|
274
|
+
>
|
|
275
|
+
{userPaused ? <Play className="size-3.5" /> : <Pause className="size-3.5" />}
|
|
276
|
+
<span className="sr-only">
|
|
277
|
+
{userPaused ? "Start automatic slideshow" : "Pause automatic slideshow"}
|
|
278
|
+
</span>
|
|
279
|
+
</button>
|
|
280
|
+
) : null}
|
|
281
|
+
</div>
|
|
282
|
+
) : null}
|
|
283
|
+
|
|
284
|
+
<div aria-live="polite" aria-atomic="true" className="sr-only">
|
|
285
|
+
{`Slide ${index + 1} of ${count}`}
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
);
|
|
289
|
+
}
|