@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.
Files changed (88) hide show
  1. package/CHANGELOG.md +241 -0
  2. package/README.md +23 -1
  3. package/docs/GOOGLE-SHEETS.md +145 -0
  4. package/docs/LAYOUT-BLOCKS.md +125 -0
  5. package/docs/MEDIA-FOLDERS.md +90 -0
  6. package/docs/NEW-SITE.md +35 -0
  7. package/docs/PAGES.md +452 -0
  8. package/docs/SCHEDULING.md +130 -0
  9. package/docs/TYPOGRAPHY.md +86 -0
  10. package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
  11. package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
  12. package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
  13. package/package.json +5 -1
  14. package/src/backoffice/DomainsView.tsx +152 -25
  15. package/src/backoffice/FormEditorView.tsx +10 -0
  16. package/src/backoffice/FormSettingsView.tsx +8 -0
  17. package/src/backoffice/MediaLibraryView.tsx +239 -21
  18. package/src/backoffice/NavigationView.tsx +43 -0
  19. package/src/backoffice/PageBuilderView.tsx +193 -36
  20. package/src/backoffice/PageEditorView.tsx +293 -57
  21. package/src/backoffice/PagesListView.tsx +294 -19
  22. package/src/backoffice/ScheduleFields.tsx +151 -0
  23. package/src/backoffice/SectionFormView.tsx +58 -0
  24. package/src/backoffice/StatusBadge.tsx +174 -0
  25. package/src/backoffice/TrackingView.tsx +247 -208
  26. package/src/backoffice/TypographyPanel.tsx +308 -0
  27. package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
  28. package/src/backoffice/inspectors/blocks.tsx +149 -0
  29. package/src/backoffice/inspectors/fields.tsx +81 -0
  30. package/src/backoffice/inspectors/index.tsx +3 -0
  31. package/src/backoffice/media/ConfirmDialog.tsx +64 -4
  32. package/src/backoffice/routes/host.ts +31 -0
  33. package/src/backoffice/routes/index.tsx +140 -15
  34. package/src/backoffice/routes/media-forms.tsx +5 -0
  35. package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
  36. package/src/backoffice/seo/page-seo-form.ts +11 -1
  37. package/src/backoffice/shell/domain-guard.ts +27 -14
  38. package/src/backoffice/shell/index.ts +5 -0
  39. package/src/backoffice/shell/use-resolved-config.tsx +67 -0
  40. package/src/backoffice/tracking-drafts.ts +150 -0
  41. package/src/blocks/block-children.tsx +40 -0
  42. package/src/blocks/block-tree.ts +378 -0
  43. package/src/blocks/components/IconGridBlock.tsx +76 -51
  44. package/src/blocks/components/LayoutBlock.tsx +100 -0
  45. package/src/blocks/countdown-time.ts +17 -87
  46. package/src/blocks/registry.tsx +21 -0
  47. package/src/blocks/types.ts +40 -0
  48. package/src/cms/page-publication.ts +377 -0
  49. package/src/cms/page-status.ts +152 -0
  50. package/src/cms/timezone.ts +181 -0
  51. package/src/cms/use-publication-boundary.ts +84 -0
  52. package/src/cms/use-schedule-clock.ts +43 -0
  53. package/src/cms/visibility-schedule.ts +267 -0
  54. package/src/config/modules.ts +34 -10
  55. package/src/config/site-config.ts +32 -0
  56. package/src/config/typography.ts +143 -0
  57. package/src/http/cache-policy.ts +49 -2
  58. package/src/media/folders.ts +183 -0
  59. package/src/media/use-media-upload.ts +21 -4
  60. package/src/navigation/index.ts +34 -2
  61. package/src/platform/contract.ts +52 -1
  62. package/src/runtime/server-entry.ts +8 -2
  63. package/src/server/content-sections-core.ts +16 -0
  64. package/src/server/domains-core.ts +124 -107
  65. package/src/server/forms-delivery-core.ts +61 -10
  66. package/src/server/forms-submissions-core.ts +47 -0
  67. package/src/server/google-service-account.ts +325 -0
  68. package/src/server/google-sheets-core.ts +463 -0
  69. package/src/server/http/sitemap.ts +32 -8
  70. package/src/server/injections-core.ts +230 -49
  71. package/src/server/media-library-core.ts +27 -6
  72. package/src/server/pages-core.ts +680 -184
  73. package/src/server/published-cache.ts +25 -4
  74. package/src/server/resolve-globals.ts +6 -1
  75. package/src/server/schema-drift.ts +4 -0
  76. package/src/server/schemas.ts +15 -0
  77. package/src/site/PageRenderer.tsx +45 -12
  78. package/src/site/SiteFooter.tsx +44 -3
  79. package/src/site/SiteHeader.tsx +23 -2
  80. package/src/site/google-fonts.ts +191 -0
  81. package/src/site/host.ts +172 -0
  82. package/src/site/locale-domains.ts +500 -0
  83. package/src/site/routes/public-page.tsx +12 -1
  84. package/src/site/routes.ts +206 -38
  85. package/src/site/site-url.ts +80 -17
  86. package/src/site/typography.tsx +75 -0
  87. package/src/tracking/injections.ts +52 -9
  88. 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
+ }