@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
@@ -10,7 +10,17 @@
10
10
  import { useEffect, useState, type ReactNode } from "react";
11
11
 
12
12
  import { EDIT_LOCK_TYPES } from "../cms/edit-lock";
13
+ import {
14
+ isPubliclyVisible,
15
+ type PublicationState,
16
+ type PublicationTransitionKind,
17
+ } from "../cms/page-publication";
18
+ import { normalizePageStatus, statusChangeConfirmation } from "../cms/page-status";
19
+ import { usePublicationBoundaryRefresh } from "../cms/use-publication-boundary";
20
+ import { zoneAbbreviation, zonedLocalExists, zonedLocalToInstant } from "../cms/timezone";
21
+ import { siteTimezone } from "../config/site-config";
13
22
  import { EditLockBanner, EditLockFence, useEditLock } from "./EditLock";
23
+ import { PageStatusBadge, PublicationBadge } from "./StatusBadge";
14
24
  import {
15
25
  ArrowLeft,
16
26
  Save,
@@ -25,7 +35,29 @@ import {
25
35
  } from "lucide-react";
26
36
 
27
37
  /** A page row as the builder needs it. */
28
- export type BuilderPage = { id: string; key: string; name: string; status: string };
38
+ export type BuilderPage = {
39
+ id: string;
40
+ key: string;
41
+ name: string;
42
+ /** The editorial status an editor last set — not public visibility. */
43
+ status: string;
44
+ /**
45
+ * What a visitor gets right now, from `getPageForEditCore`. The header shows
46
+ * this rather than `status`, because the two disagree exactly when it
47
+ * matters: a scheduled draft is public, an expired published page is not.
48
+ */
49
+ publication: PublicationState;
50
+ /** When `publication` next changes, ISO-8601, or null when nothing is due. */
51
+ publication_at?: string | null;
52
+ /**
53
+ * What happens at `publication_at`. A replacement is neither a publish nor a
54
+ * hide, and a header that calls it either misleads about the one thing this
55
+ * screen exists to tell an editor.
56
+ */
57
+ publication_transition?: PublicationTransitionKind | null;
58
+ /** The version showing after that, for a publish or a replacement. */
59
+ publication_next_version_id?: string | null;
60
+ };
29
61
 
30
62
  /** One saved version, with its computed change summary. */
31
63
  export type BuilderVersion = {
@@ -42,6 +74,8 @@ export type BuilderVersion = {
42
74
  /** One scheduled publication row. */
43
75
  export type BuilderPublication = {
44
76
  id: string;
77
+ /** Which version this row publishes; marks the one taking over next. */
78
+ version_id?: string | null;
45
79
  publish_at: string;
46
80
  unpublish_at?: string | null;
47
81
  note?: string | null;
@@ -64,9 +98,23 @@ export type PageBuilderDeps<TBlock> = {
64
98
  locales: readonly string[];
65
99
  localeLabels: Record<string, string>;
66
100
  defaultLocale: string;
67
- /** Public path for this page in the given locale. */
101
+ /**
102
+ * Site-relative path for this page in the given locale.
103
+ *
104
+ * A path on its own is not a public address. It only becomes one when
105
+ * `publicHost` supplies the hostname it belongs to.
106
+ */
68
107
  publicPath: (locale: string) => string;
69
- /** Host name to build the absolute URL with, for the given locale. */
108
+ /**
109
+ * The public hostname for this locale, or `""` when there is none.
110
+ *
111
+ * Since m7.10 a deployment's locale-to-domain table is explicit: a locale
112
+ * that is not in it has no public site. `""` is that answer — NO PUBLIC URL
113
+ * EXISTS — and it must never be read as "use a relative URL" or "fall back
114
+ * to the current host". Doing so hands the browser a path it resolves
115
+ * against whatever hostname the backoffice happens to be served from,
116
+ * inventing a public address for a locale the deployment does not publish.
117
+ */
70
118
  publicHost: (locale: string) => string;
71
119
 
72
120
  versions: BuilderVersion[] | undefined;
@@ -139,6 +187,8 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
139
187
  const [publishAt, setPublishAt] = useState("");
140
188
  const [unpublishAt, setUnpublishAt] = useState("");
141
189
  const [scheduleNote, setScheduleNote] = useState("");
190
+ const timezone = siteTimezone();
191
+ const zone = zoneAbbreviation(timezone);
142
192
  const [renameValue, setRenameValue] = useState<string | null>(null);
143
193
  const [locale, setLocale] = useState<string>(defaultLocale);
144
194
  const [copied, setCopied] = useState(false);
@@ -154,9 +204,22 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
154
204
  }
155
205
  }, [blocksFromServer]);
156
206
 
207
+ // Re-read the page when the next scheduled transition falls due, so a tab
208
+ // left open across a publish time stops claiming the page is still hidden.
209
+ // The hook owns the waiting, including what to do at a boundary whose fresh
210
+ // data has not arrived yet; see `use-publication-boundary`.
211
+ usePublicationBoundaryRefresh(page?.publication_at ?? null, () => {
212
+ deps.refetchPage();
213
+ deps.refetchPublications();
214
+ });
215
+
157
216
  const slugPath = deps.publicPath(locale);
158
217
  const host = deps.publicHost(locale);
159
- const absoluteUrl = host ? `https://${host}${slugPath}` : slugPath;
218
+ // No hostname, no public URL. A relative href here would be resolved against
219
+ // the backoffice hostname, which is not where this page is published — and
220
+ // for an unmapped locale it is not published anywhere.
221
+ const publicUrl = host ? `https://${host}${slugPath}` : null;
222
+ const localeName = localeLabels[locale] ?? locale;
160
223
 
161
224
  const togglePanel = (panel: "history" | "schedule") => {
162
225
  if (panel === "history") {
@@ -171,8 +234,9 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
171
234
  };
172
235
 
173
236
  const onCopyUrl = async () => {
237
+ if (!publicUrl) return;
174
238
  try {
175
- await navigator.clipboard.writeText(absoluteUrl);
239
+ await navigator.clipboard.writeText(publicUrl);
176
240
  setCopied(true);
177
241
  setTimeout(() => setCopied(false), 1500);
178
242
  } catch {
@@ -198,6 +262,19 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
198
262
 
199
263
  const onSave = async (publish: boolean) => {
200
264
  if (!page) return;
265
+ /*
266
+ * Publishing puts the current canvas in front of every visitor, and the
267
+ * button sits next to "Save draft". Confirm it the same way cancelling a
268
+ * publication is confirmed — one keystroke should not go live.
269
+ */
270
+ if (
271
+ publish &&
272
+ !confirm(
273
+ `Publish "${page.name}" to the live site now?\n\n` +
274
+ statusChangeConfirmation("published", page.publication, page.publication_transition),
275
+ )
276
+ )
277
+ return;
201
278
  setBusy(true);
202
279
  try {
203
280
  // Shared content-section edits made in the inspector are part of the page
@@ -220,6 +297,25 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
220
297
  notify.error("Pick a publish date/time");
221
298
  return;
222
299
  }
300
+ /*
301
+ * Wall-clock in the *site's* timezone, not the browser's: an editor abroad
302
+ * must schedule the same instant a colleague at home would. `new Date(...)`
303
+ * on a datetime-local value reads it in whatever zone the laptop is set to.
304
+ */
305
+ const from = zonedLocalToInstant(publishAt, timezone);
306
+ const until = unpublishAt ? zonedLocalToInstant(unpublishAt, timezone) : null;
307
+ if (from == null || !zonedLocalExists(publishAt, timezone)) {
308
+ notify.error(`${publishAt.replace("T", " ")} does not exist in ${zone} — the clocks jump forward that night. Pick another time.`);
309
+ return;
310
+ }
311
+ if (unpublishAt && (until == null || !zonedLocalExists(unpublishAt, timezone))) {
312
+ notify.error(`${unpublishAt.replace("T", " ")} does not exist in ${zone} — the clocks jump forward that night. Pick another time.`);
313
+ return;
314
+ }
315
+ if (until != null && until <= from) {
316
+ notify.error("Unpublish time must be after the publish time.");
317
+ return;
318
+ }
223
319
  setBusy(true);
224
320
  try {
225
321
  const res = await deps.save({
@@ -231,8 +327,8 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
231
327
  });
232
328
  await deps.schedule({
233
329
  versionId: res.versionId,
234
- publishAt: new Date(publishAt).toISOString(),
235
- unpublishAt: unpublishAt ? new Date(unpublishAt).toISOString() : null,
330
+ publishAt: new Date(from).toISOString(),
331
+ unpublishAt: until == null ? null : new Date(until).toISOString(),
236
332
  note: scheduleNote || undefined,
237
333
  editSessionId: lock.sessionId,
238
334
  });
@@ -250,14 +346,36 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
250
346
  }
251
347
  };
252
348
 
253
- const onCancelPub = async (id: string) => {
254
- if (!confirm("Cancel this scheduled publication?")) return;
349
+ /**
350
+ * Call off a publication that has not started.
351
+ *
352
+ * The button is only offered on those, but "has not started" is a fact about
353
+ * the clock and this screen may have been open for an hour: the row can begin
354
+ * between the render and the click. The server checks it again against the
355
+ * database clock and refuses, so the failure path here is a real one — say
356
+ * what happened and re-read the page rather than leaving a stale screen with
357
+ * a Cancel button that no longer applies.
358
+ */
359
+ const onCancelPub = async (p: BuilderPublication) => {
360
+ const starts = new Date(p.publish_at).toLocaleString();
361
+ if (
362
+ !confirm(
363
+ `Cancel the publication scheduled for ${starts}?\n\n` +
364
+ "It will not happen, and what visitors see right now does not change.",
365
+ )
366
+ )
367
+ return;
255
368
  try {
256
- await deps.cancelPublication(id, lock.sessionId);
369
+ await deps.cancelPublication(p.id, lock.sessionId);
257
370
  notify.success("Cancelled");
258
- deps.refetchPublications();
259
371
  } catch (e) {
260
372
  notify.error(e instanceof Error ? e.message : "Failed");
373
+ } finally {
374
+ // Both, always: cancelling changes the schedule *and* what the header
375
+ // says happens next, and refetching only the list left the header
376
+ // describing a transition that had just been called off.
377
+ deps.refetchPublications();
378
+ deps.refetchPage();
261
379
  }
262
380
  };
263
381
 
@@ -329,24 +447,40 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
329
447
  </div>
330
448
  )}
331
449
  <div className="mt-2 flex flex-wrap items-center gap-2">
332
- <a
333
- href={absoluteUrl}
334
- target="_blank"
335
- rel="noreferrer"
336
- title={`Open ${absoluteUrl}`}
337
- className="inline-flex items-center gap-1.5 border border-ink/15 bg-white px-2.5 py-1 font-mono text-xs text-ink hover:border-sea"
338
- >
339
- {slugPath}
340
- <ExternalLink className="size-3 text-ink/40" />
341
- </a>
342
- <button
343
- onClick={onCopyUrl}
344
- title="Copy full URL"
345
- className="inline-flex items-center gap-1.5 border border-ink/15 px-2 py-1 text-xs text-ink/60 hover:border-sea hover:text-ink"
346
- >
347
- {copied ? <Check className="size-3" /> : <Copy className="size-3" />}
348
- {copied ? "Copied" : "Copy URL"}
349
- </button>
450
+ {publicUrl ? (
451
+ <>
452
+ <a
453
+ href={publicUrl}
454
+ target="_blank"
455
+ rel="noreferrer"
456
+ title={`Open ${publicUrl}`}
457
+ className="inline-flex items-center gap-1.5 border border-ink/15 bg-white px-2.5 py-1 font-mono text-xs text-ink hover:border-sea"
458
+ >
459
+ {slugPath}
460
+ <ExternalLink className="size-3 text-ink/40" />
461
+ </a>
462
+ <button
463
+ onClick={onCopyUrl}
464
+ title="Copy full URL"
465
+ className="inline-flex items-center gap-1.5 border border-ink/15 px-2 py-1 text-xs text-ink/60 hover:border-sea hover:text-ink"
466
+ >
467
+ {copied ? <Check className="size-3" /> : <Copy className="size-3" />}
468
+ {copied ? "Copied" : "Copy URL"}
469
+ </button>
470
+ </>
471
+ ) : (
472
+ /* Not a link and not a copy target: there is no address to open
473
+ or to paste into a message. The path is still shown, because
474
+ it is the one true thing here — where this page would live if
475
+ the locale were given a domain. */
476
+ <span
477
+ className="inline-flex items-center gap-1.5 border border-dashed border-ink/20 px-2.5 py-1 font-mono text-xs text-ink/50"
478
+ title={`${localeName} has no public domain, so this page has no public address.`}
479
+ >
480
+ {slugPath}
481
+ <span className="font-sans">· No domain configured for {localeName}</span>
482
+ </span>
483
+ )}
350
484
  {locales.length > 1 && (
351
485
  <div className="flex items-center gap-1">
352
486
  {locales.map((loc) => (
@@ -365,8 +499,16 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
365
499
  </div>
366
500
  )}
367
501
  </div>
502
+ <div className="mt-2 flex flex-wrap items-center gap-2">
503
+ <PublicationBadge
504
+ state={page.publication}
505
+ at={page.publication_at}
506
+ transition={page.publication_transition}
507
+ />
508
+ <PageStatusBadge status={normalizePageStatus(page.status)} />
509
+ </div>
368
510
  <div className="text-xs text-ink/50 font-mono mt-2">
369
- {page.key} · {page.status}
511
+ {page.key}
370
512
  {dirty && " · unsaved changes"}
371
513
  </div>
372
514
  </div>
@@ -401,7 +543,8 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
401
543
  onClick={() => onSave(true)}
402
544
  className="inline-flex items-center gap-2 bg-ink text-white px-4 py-2 text-sm disabled:opacity-40"
403
545
  >
404
- <Rocket className="size-4" /> Publish now
546
+ <Rocket className="size-4" />{" "}
547
+ {isPubliclyVisible(page.publication) ? "Publish update now" : "Publish now"}
405
548
  </button>
406
549
  </div>
407
550
  </div>
@@ -464,8 +607,9 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
464
607
  </div>
465
608
  </div>
466
609
  <p className="mt-3 text-xs text-ink/60">
467
- The current canvas is saved as a new draft version and scheduled to become live at that
468
- time. The site auto-selects the newest active publication whose window is open.
610
+ Times are in the site&rsquo;s timezone ({zone}). The current canvas is saved as a new
611
+ draft version and scheduled to become live at that time. The site auto-selects the
612
+ newest active publication whose window is open.
469
613
  </p>
470
614
 
471
615
  <div className="mt-6">
@@ -504,6 +648,11 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
504
648
  <span className="ml-3 font-mono text-ink">
505
649
  v{p.page_versions?.version_no ?? "?"}
506
650
  </span>
651
+ {p.version_id && p.version_id === page.publication_next_version_id ? (
652
+ <span className="ml-2 text-[0.65rem] uppercase tracking-wider text-sea">
653
+ next
654
+ </span>
655
+ ) : null}
507
656
  <span className="ml-3 text-ink/70">
508
657
  {new Date(p.publish_at).toLocaleString()}
509
658
  </span>
@@ -514,15 +663,23 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
514
663
  )}
515
664
  {p.note && <span className="ml-3 text-ink/60 italic">{p.note}</span>}
516
665
  </div>
517
- {p.is_active && status !== "expired" && (
666
+ {status === "scheduled" ? (
518
667
  <button
519
668
  className="text-red-600 hover:underline disabled:opacity-40 disabled:no-underline"
520
669
  disabled={lock.blocked}
521
- onClick={() => onCancelPub(p.id)}
670
+ onClick={() => onCancelPub(p)}
522
671
  >
523
672
  Cancel
524
673
  </button>
525
- )}
674
+ ) : status === "active" ? (
675
+ // Retiring this row does not cancel anything: it takes
676
+ // the page off the public website, and where an older
677
+ // row is still active behind it, puts that version back.
678
+ // That is Unpublish, and it asks its own question.
679
+ <span className="text-xs text-ink/45">
680
+ Live — unpublish the page to take it down
681
+ </span>
682
+ ) : null}
526
683
  </div>
527
684
  );
528
685
  })}