@bygghammar/cms-platform 0.92.2 → 0.94.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.
@@ -1,6 +1,13 @@
1
- import { Package } from "lucide-react";
2
- import { useEffect, useState } from "react";
1
+ import { Check, Copy, Package, RefreshCw } from "lucide-react";
2
+ import { useEffect, useRef, useState, type ReactNode } from "react";
3
3
 
4
+ import type { ModuleInventoryRow } from "../admin/module-inventory";
5
+ import {
6
+ summarizeUpdates,
7
+ type PackageReport,
8
+ type UpdateStatus,
9
+ type VersionReport,
10
+ } from "../admin/version-report";
4
11
  import { SCHEMA_BASELINE, SCHEMA_MIGRATIONS, type SchemaDrift } from "../server/schema-drift";
5
12
 
6
13
  /** Build metadata injected at compile time by the host. */
@@ -27,6 +34,32 @@ export type VersionDeps = {
27
34
  packageName?: string;
28
35
  /** Schema comparison against the packaged baseline, when the host supplies it. */
29
36
  drift?: SchemaDrift;
37
+ /**
38
+ * The release/package report, from hosts that build one with
39
+ * `buildVersionReport`. Without it the screen shows what `build` and
40
+ * `releases` say and marks the rest as not reported.
41
+ */
42
+ report?: VersionReport | null;
43
+ /** One row per module (see `moduleInventory`). Omitted: no module section. */
44
+ modules?: readonly ModuleInventoryRow[];
45
+ /** The version read failed and there is no earlier answer to show. */
46
+ error?: boolean;
47
+ /**
48
+ * The most recent request for this information (a "Check again" or a
49
+ * re-read) failed while an earlier answer is on screen. The screen keeps
50
+ * that answer but marks it as not re-verified.
51
+ */
52
+ latestCheckFailed?: boolean;
53
+ onRetry?: () => void;
54
+ /** Ask the server to check the registry again (it applies its own minimum interval). */
55
+ onRefresh?: () => void;
56
+ refreshing?: boolean;
57
+ /** Shown as "<siteName> release". */
58
+ siteName?: string;
59
+ /** Link target for "change modules in Site settings". */
60
+ settingsHref?: string;
61
+ /** Renders a link; the route passes the router's `Link`. Defaults to `<a>`. */
62
+ renderLink?: (props: { href: string; className: string; children: ReactNode }) => ReactNode;
30
63
  };
31
64
 
32
65
  function fmt(value: string | null | undefined) {
@@ -36,6 +69,13 @@ function fmt(value: string | null | undefined) {
36
69
  return d.toLocaleString("sv-SE", { timeZone: "UTC", hour12: false }) + " UTC";
37
70
  }
38
71
 
72
+ function fmtDate(value: string | null | undefined) {
73
+ if (!value) return null;
74
+ const d = new Date(value);
75
+ if (Number.isNaN(d.getTime())) return null;
76
+ return d.toLocaleDateString("sv-SE", { timeZone: "UTC" });
77
+ }
78
+
39
79
  function formatUtc(date: Date) {
40
80
  return date.toLocaleString("sv-SE", { timeZone: "UTC", hour12: false }) + " UTC";
41
81
  }
@@ -51,49 +91,694 @@ function useUtcClock() {
51
91
  return now;
52
92
  }
53
93
 
54
- /** Host-agnostic CMS & platform version screen. */
94
+ const LABEL = "text-[0.7rem] uppercase tracking-[0.22em] text-ink/50";
95
+ const CARD = "border border-ink/10 bg-white p-5";
96
+ const MODULE_COLUMNS = "lg:grid-cols-[minmax(0,1.1fr)_10rem_minmax(0,1.9fr)]";
97
+ const PILL =
98
+ "inline-flex items-center whitespace-nowrap rounded-full px-3 py-1 text-[0.7rem] uppercase tracking-[0.16em]";
99
+
100
+ const STATUS_TEXT: Record<UpdateStatus, string> = {
101
+ "up-to-date": "Up to date",
102
+ "update-available": "Update available",
103
+ "prerelease-current": "Prerelease installed",
104
+ "check-unavailable": "Update check unavailable",
105
+ "not-published": "Not in registry",
106
+ "ahead-of-registry": "Ahead of registry",
107
+ "installed-unknown": "Installed version unknown",
108
+ "installed-invalid": "Unrecognised version",
109
+ };
110
+
111
+ const STATUS_TONE: Record<UpdateStatus, string> = {
112
+ "up-to-date": "bg-sea/10 text-sea",
113
+ "update-available": "bg-amber-100 text-amber-900",
114
+ "prerelease-current": "bg-ink/5 text-ink/75",
115
+ "check-unavailable": "bg-ink/5 text-ink/60",
116
+ "not-published": "bg-amber-100 text-amber-900",
117
+ "ahead-of-registry": "bg-amber-100 text-amber-900",
118
+ "installed-unknown": "bg-amber-100 text-amber-900",
119
+ "installed-invalid": "bg-amber-100 text-amber-900",
120
+ };
121
+
122
+ function StatusBadge({ status }: { status: UpdateStatus }) {
123
+ return <span className={`${PILL} ${STATUS_TONE[status]}`}>{STATUS_TEXT[status]}</span>;
124
+ }
125
+
126
+ const REGISTRY_ERROR_TEXT: Record<string, string> = {
127
+ timeout: "the registry did not answer in time",
128
+ network: "the registry could not be reached",
129
+ http: "the registry returned an error",
130
+ "not-found": "the registry does not list this package",
131
+ malformed: "the registry answer was not usable",
132
+ "too-large": "the registry answer was too large",
133
+ "not-allowed": "this package is not on the update-check allowlist",
134
+ };
135
+
136
+ /** A short label/value pair; the label stays readable on every width. */
137
+ function Field({ label, children }: { label: string; children: ReactNode }) {
138
+ return (
139
+ <div className="min-w-0">
140
+ <dt className={LABEL}>{label}</dt>
141
+ <dd className="mt-1.5 min-w-0 break-words text-sm text-ink">{children}</dd>
142
+ </div>
143
+ );
144
+ }
145
+
146
+ function Mono({ children }: { children: ReactNode }) {
147
+ return <span className="font-mono [overflow-wrap:anywhere]">{children}</span>;
148
+ }
149
+
150
+ function Muted({ children }: { children: ReactNode }) {
151
+ return <span className="mt-1 block text-xs text-ink/55">{children}</span>;
152
+ }
153
+
154
+ function CopyButton({ value, label }: { value: string; label: string }) {
155
+ const [state, setState] = useState<"idle" | "copied" | "failed">("idle");
156
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
157
+ useEffect(() => () => {
158
+ if (timer.current) clearTimeout(timer.current);
159
+ }, []);
160
+ const copy = async () => {
161
+ try {
162
+ if (!navigator.clipboard?.writeText) throw new Error("no clipboard");
163
+ await navigator.clipboard.writeText(value);
164
+ setState("copied");
165
+ } catch {
166
+ setState("failed");
167
+ }
168
+ if (timer.current) clearTimeout(timer.current);
169
+ timer.current = setTimeout(() => setState("idle"), 2500);
170
+ };
171
+ return (
172
+ <span className="inline-flex items-center gap-2">
173
+ <button
174
+ type="button"
175
+ onClick={() => void copy()}
176
+ aria-label={label}
177
+ className="inline-flex min-h-9 items-center gap-1.5 border border-ink/15 px-3 py-1.5 text-xs text-ink/75 hover:border-sea hover:text-sea focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sea"
178
+ >
179
+ {state === "copied" ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
180
+ {state === "copied" ? "Copied" : "Copy"}
181
+ </button>
182
+ <span role="status" aria-live="polite" className="text-xs text-ink/55">
183
+ {state === "failed" ? "Copy is not available here — select the full value instead." : ""}
184
+ </span>
185
+ </span>
186
+ );
187
+ }
188
+
189
+ function ReleaseValue({ report }: { report: VersionReport | null | undefined }) {
190
+ const release = report?.release;
191
+ if (!release?.value) {
192
+ return (
193
+ <>
194
+ <span className="font-serif text-lg text-ink">Unknown</span>
195
+ <Muted>
196
+ {!report
197
+ ? "This site's server does not report its release identity."
198
+ : release?.unrecognized
199
+ ? `The runtime release value (${release.source}) is not in a recognised format, so it is not shown.`
200
+ : "This deployment did not report a release identity (APP_VERSION is not set)."}
201
+ </Muted>
202
+ </>
203
+ );
204
+ }
205
+ const short = release.kind === "commit" ? release.value.slice(0, 8) : release.value;
206
+ return (
207
+ <>
208
+ <span className="font-mono text-lg text-ink" title={release.value}>
209
+ {short}
210
+ </span>
211
+ {release.kind === "commit" && (
212
+ <span className="mt-2 block">
213
+ <span className="sr-only">Full commit: </span>
214
+ <code className="block font-mono text-xs text-ink/60 [overflow-wrap:anywhere]">
215
+ {release.value}
216
+ </code>
217
+ </span>
218
+ )}
219
+ <span className="mt-3 flex flex-wrap items-center gap-2">
220
+ <CopyButton
221
+ value={release.value}
222
+ label={release.kind === "commit" ? "Copy full release commit" : "Copy release identity"}
223
+ />
224
+ </span>
225
+ <Muted>
226
+ From the runtime <span className="font-mono">{release.source}</span>, the value the health
227
+ check reports.
228
+ </Muted>
229
+ </>
230
+ );
231
+ }
232
+
233
+ function PackageVersions({ pkg }: { pkg: PackageReport }) {
234
+ const u = pkg.update;
235
+ const installedDate = fmtDate(u.installedPublishedAt);
236
+ return (
237
+ <>
238
+ <Mono>{pkg.installed ?? "Unknown"}</Mono>
239
+ <Muted>
240
+ {installedDate
241
+ ? `Published ${installedDate}`
242
+ : pkg.registry.state === "unavailable"
243
+ ? "Publication date not available"
244
+ : "No publication date in the registry"}
245
+ {u.installedTags.length > 0 && ` · tag ${u.installedTags.join(", ")}`}
246
+ {u.installedDeprecated && " · deprecated"}
247
+ </Muted>
248
+ </>
249
+ );
250
+ }
251
+
252
+ function LatestVersion({ pkg }: { pkg: PackageReport }) {
253
+ const u = pkg.update;
254
+ const channel =
255
+ u.channel?.kind === "prerelease" ? `${u.channel.line} prerelease line` : "stable releases";
256
+ if (!u.latestInChannel) {
257
+ return (
258
+ <>
259
+ <span className="text-ink/60">—</span>
260
+ <Muted>
261
+ {u.status === "check-unavailable" || u.status === "installed-unknown"
262
+ ? "Not checked"
263
+ : `Nothing published in ${channel}`}
264
+ </Muted>
265
+ </>
266
+ );
267
+ }
268
+ const date = fmtDate(u.latestInChannel.publishedAt);
269
+ return (
270
+ <>
271
+ <Mono>{u.latestInChannel.version}</Mono>
272
+ <Muted>
273
+ Newest in {channel}
274
+ {date ? `, published ${date}` : ""}
275
+ </Muted>
276
+ {u.newerStable && (
277
+ <Muted>
278
+ Stable <span className="font-mono">{u.newerStable.version}</span> is also published
279
+ {fmtDate(u.newerStable.publishedAt) ? ` (${fmtDate(u.newerStable.publishedAt)})` : ""}.
280
+ </Muted>
281
+ )}
282
+ </>
283
+ );
284
+ }
285
+
286
+ function registryReason(error: string | null): string {
287
+ return error ? (REGISTRY_ERROR_TEXT[error] ?? "the check failed") : "";
288
+ }
289
+
290
+ function registryNote(pkg: PackageReport): string | null {
291
+ const r = pkg.registry;
292
+ if (r.state === "unavailable") {
293
+ return r.error ? `Update check unavailable: ${registryReason(r.error)}.` : null;
294
+ }
295
+ if (r.state === "stale") {
296
+ return r.error
297
+ ? `Latest check failed at ${fmt(r.lastAttemptAt)}: ${registryReason(r.error)}. Showing registry data cached from ${fmt(r.lastSuccessAt)}, not re-verified.`
298
+ : `Showing registry data cached from ${fmt(r.lastSuccessAt)}, not re-verified.`;
299
+ }
300
+ return null;
301
+ }
302
+
303
+ const ENABLED_TEXT = {
304
+ enabled: "Enabled",
305
+ disabled: "Disabled",
306
+ checking: "Checking…",
307
+ unavailable: "Status unavailable",
308
+ } as const;
309
+
310
+ const ENABLED_TONE = {
311
+ enabled: "bg-sea/10 text-sea",
312
+ disabled: "bg-ink/5 text-ink/60",
313
+ checking: "bg-ink/5 text-ink/50",
314
+ unavailable: "bg-amber-100 text-amber-900",
315
+ } as const;
316
+
317
+ function UpdateSummaryBanner({
318
+ report,
319
+ error,
320
+ latestCheckFailed,
321
+ onRefresh,
322
+ refreshing,
323
+ }: {
324
+ report: VersionReport | null | undefined;
325
+ error?: boolean;
326
+ latestCheckFailed?: boolean;
327
+ onRefresh?: () => void;
328
+ refreshing?: boolean;
329
+ }) {
330
+ const summary = report ? summarizeUpdates(report.packages) : null;
331
+ // Not verified: the latest request failed, or the registry answers on screen
332
+ // are cached because their latest check failed. The verdict below is then the
333
+ // one from the last successful check, and is labelled as such.
334
+ const registryFailed = !!summary && summary.kind !== "unavailable" && !summary.verified;
335
+ const unverified = !!summary && (registryFailed || !!latestCheckFailed);
336
+ // Claims about successful registry checks use only registry success times.
337
+ // A package whose latest attempt failed is either cached (it succeeded
338
+ // before) or has never been checked successfully; the two are worded apart.
339
+ const packages = report?.packages ?? [];
340
+ const succeeded = packages.filter((p) => p.registry.lastSuccessAt !== null);
341
+ const neverSucceeded = packages.filter((p) => p.registry.lastSuccessAt === null);
342
+ const cachedAfterFailure = packages.filter(
343
+ (p) => p.registry.state === "stale" && p.registry.error !== null,
344
+ );
345
+ const firstTimeFailed = neverSucceeded.filter((p) => p.registry.error !== null);
346
+ const names = (list: readonly PackageReport[]) => list.map((p) => p.name).join(", ");
347
+ const oldestSuccess = (list: readonly PackageReport[]) =>
348
+ list.length
349
+ ? list.map((p) => p.registry.lastSuccessAt as string).reduce((a, b) => (a < b ? a : b))
350
+ : null;
351
+ const registrySentence =
352
+ packages.length > 0 && succeeded.length === packages.length
353
+ ? `Its last successful registry check was ${fmt(summary?.checkedAt)}.`
354
+ : succeeded.length > 0
355
+ ? `Its last successful registry check was ${fmt(oldestSuccess(succeeded))} for ${names(succeeded)}; ${names(neverSucceeded)} had no successful registry check.`
356
+ : "It contained no successful registry check.";
357
+ let headline: string;
358
+ let detail: string;
359
+ if (!summary) {
360
+ headline = error ? "Version information could not be loaded" : "Update check unavailable";
361
+ detail = error
362
+ ? "The installed versions and update status could not be read right now."
363
+ : "This site's server does not report package updates.";
364
+ } else if (summary.kind === "updates-available") {
365
+ headline =
366
+ summary.updates.length === 1
367
+ ? "1 package update available"
368
+ : `${summary.updates.length} package updates available`;
369
+ detail = `For ${summary.updates.join(", ")}. Updates are installed through a reviewed release, not from this screen.`;
370
+ } else if (summary.kind === "attention") {
371
+ headline = "Check the installed versions";
372
+ detail = `The registry cannot confirm ${summary.attention.join(", ")}.`;
373
+ } else if (summary.kind === "unavailable") {
374
+ headline = "Update check unavailable";
375
+ detail = "Installed versions are shown below; the public registry could not be compared.";
376
+ } else if (summary.kind === "partial") {
377
+ headline = "Partly checked";
378
+ detail = `No update found, but ${summary.unavailable.join(", ")} could not be checked.`;
379
+ } else {
380
+ headline = unverified ? "Up to date at the last successful check" : "Up to date";
381
+ detail = "Every installed first-party package is the newest in its channel.";
382
+ }
383
+ if (summary && summary.prereleases.length > 0) {
384
+ detail += ` Prerelease installed: ${summary.prereleases.join(", ")}.`;
385
+ }
386
+ const tone =
387
+ summary?.kind === "up-to-date" && !unverified
388
+ ? "border-sea/30"
389
+ : summary?.kind === "updates-available" || summary?.kind === "attention" || error || unverified
390
+ ? "border-amber-300 bg-amber-50"
391
+ : "border-ink/10";
392
+ const nextAt = report?.nextCheckAllowedAt ? Date.parse(report.nextCheckAllowedAt) : null;
393
+
394
+ return (
395
+ <section aria-labelledby="version-summary" className={`mt-8 border bg-white p-5 ${tone}`}>
396
+ <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
397
+ <div className="min-w-0 sm:flex-1">
398
+ <h3 id="version-summary" className="font-serif text-lg text-ink">
399
+ {headline}
400
+ </h3>
401
+ <p className="mt-1 text-sm text-ink/70">{detail}</p>
402
+ {latestCheckFailed && report && (
403
+ <p role="alert" className="mt-3 border-l-2 border-amber-400 pl-3 text-sm text-ink">
404
+ The latest check could not be completed, for example because the session expired
405
+ or the connection failed. The information below is from the answer prepared at{" "}
406
+ {fmt(report.generatedAt)} and has not been re-verified. {registrySentence} Try Check
407
+ again, or reload the page to sign in again.
408
+ </p>
409
+ )}
410
+ {registryFailed && cachedAfterFailure.length > 0 && (
411
+ <p role="status" className="mt-3 border-l-2 border-amber-400 pl-3 text-sm text-ink">
412
+ The latest registry check failed for {names(cachedAfterFailure)}. Showing cached
413
+ registry data from the last successful check ({fmt(oldestSuccess(cachedAfterFailure))});
414
+ it has not been re-verified.
415
+ </p>
416
+ )}
417
+ {registryFailed && firstTimeFailed.length > 0 && (
418
+ <p role="status" className="mt-3 border-l-2 border-amber-400 pl-3 text-sm text-ink">
419
+ No successful registry check yet for {names(firstTimeFailed)};{" "}
420
+ {firstTimeFailed.length === 1
421
+ ? "its installed version is"
422
+ : "their installed versions are"}{" "}
423
+ shown without a registry comparison.
424
+ </p>
425
+ )}
426
+ {summary && (
427
+ <p className="mt-2 text-xs text-ink/55">
428
+ {packages.length > 0 && succeeded.length === packages.length
429
+ ? `Last successful registry check ${fmt(summary.checkedAt)}.`
430
+ : succeeded.length > 0
431
+ ? `Last successful registry check: ${succeeded
432
+ .map((p) => `${p.name} ${fmt(p.registry.lastSuccessAt)}`)
433
+ .join("; ")}. No successful check yet for ${names(neverSucceeded)}.`
434
+ : "No successful registry check yet."}
435
+ </p>
436
+ )}
437
+ </div>
438
+ {onRefresh && report && (
439
+ <button
440
+ type="button"
441
+ onClick={onRefresh}
442
+ disabled={refreshing}
443
+ className="inline-flex min-h-10 shrink-0 items-center gap-2 self-start border border-ink/15 bg-white px-4 py-2 text-sm text-ink hover:border-sea hover:text-sea disabled:cursor-not-allowed disabled:opacity-60 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sea"
444
+ >
445
+ <RefreshCw className={`size-4 ${refreshing ? "animate-spin" : ""}`} />
446
+ {refreshing ? "Checking…" : "Check again"}
447
+ </button>
448
+ )}
449
+ </div>
450
+ {onRefresh && report && nextAt !== null && (
451
+ <p className="mt-2 text-xs text-ink/50">
452
+ The registry is asked at most once a minute per package; a check before{" "}
453
+ {fmt(report.nextCheckAllowedAt)} reuses the last answer.
454
+ </p>
455
+ )}
456
+ </section>
457
+ );
458
+ }
459
+
460
+ function ModuleRows({
461
+ modules,
462
+ settingsHref,
463
+ renderLink,
464
+ }: {
465
+ modules: readonly ModuleInventoryRow[];
466
+ settingsHref?: string;
467
+ renderLink: NonNullable<VersionDeps["renderLink"]>;
468
+ }) {
469
+ const anyStale = modules.some((m) => m.enabledStale);
470
+ const anyUnavailable = modules.some((m) => m.enabled === "unavailable");
471
+ return (
472
+ <section aria-labelledby="version-modules" className="mt-12">
473
+ <h3 id="version-modules" className="font-serif text-lg text-ink">
474
+ Modules and packages
475
+ </h3>
476
+ <p className="mt-1 text-sm text-ink/60">
477
+ Every module in this CMS, on or off. Switching a module off hides it but keeps its code
478
+ installed.{" "}
479
+ {settingsHref
480
+ ? renderLink({
481
+ href: settingsHref,
482
+ className: "text-sea underline-offset-2 hover:underline",
483
+ children: "Turn modules on or off in Site settings.",
484
+ })
485
+ : "Turn modules on or off in Site settings."}
486
+ </p>
487
+ {anyUnavailable && (
488
+ <p role="status" className="mt-3 border border-amber-300 bg-amber-50 p-3 text-sm text-ink/80">
489
+ The saved module settings could not be read, so whether each module is on is not shown.
490
+ </p>
491
+ )}
492
+ {anyStale && (
493
+ <p role="status" className="mt-3 text-sm text-ink/60">
494
+ Module status is from the last successful settings read; a newer read failed.
495
+ </p>
496
+ )}
497
+ <div className="mt-4 border border-ink/10 bg-white">
498
+ <div
499
+ aria-hidden="true"
500
+ className={`hidden border-b border-ink/10 px-5 py-3 lg:grid ${MODULE_COLUMNS} ${LABEL}`}
501
+ >
502
+ <span>Module</span>
503
+ <span>Status</span>
504
+ <span>Source and updates</span>
505
+ </div>
506
+ <ul aria-label="Modules">
507
+ {modules.map((m) => {
508
+ const cms = m.cmsVersion ? `CMS ${m.cmsVersion}` : "the CMS package";
509
+ return (
510
+ <li
511
+ key={m.key}
512
+ className={`grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-4 gap-y-2 border-b border-ink/5 px-5 py-4 last:border-0 lg:items-center ${MODULE_COLUMNS}`}
513
+ >
514
+ <div className="min-w-0">
515
+ <h4 className="font-serif text-base text-ink">{m.label}</h4>
516
+ {m.description && <p className="mt-0.5 text-xs text-ink/55">{m.description}</p>}
517
+ </div>
518
+ <div className="justify-self-end lg:justify-self-start">
519
+ <span className={`${PILL} ${ENABLED_TONE[m.enabled]}`}>
520
+ <span className="sr-only">Status: </span>
521
+ {ENABLED_TEXT[m.enabled]}
522
+ </span>
523
+ </div>
524
+ <div className="col-span-2 min-w-0 text-sm text-ink lg:col-span-1">
525
+ <span className="block">Included in {cms}</span>
526
+ {!m.packageName && (
527
+ <span className="mt-0.5 block text-xs text-ink/55">
528
+ Updates with CMS · no separate version
529
+ </span>
530
+ )}
531
+ {m.packageName && !m.package && (
532
+ <span className="mt-0.5 block text-xs text-ink/55">
533
+ Built on <Mono>{m.packageName}</Mono> — version not reported by this server
534
+ </span>
535
+ )}
536
+ {m.packageName && m.package && (
537
+ <>
538
+ <span className="mt-0.5 block">
539
+ Built on <Mono>{m.packageName}</Mono>
540
+ </span>
541
+ <span className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
542
+ <span>
543
+ Installed <Mono>{m.package.installed ?? "unknown"}</Mono>
544
+ </span>
545
+ <StatusBadge status={m.package.update.status} />
546
+ {m.package.registry.state === "stale" && (
547
+ <span className={`${PILL} bg-amber-100 text-amber-900`}>
548
+ Not re-verified
549
+ </span>
550
+ )}
551
+ </span>
552
+ <span className="mt-1 block text-xs text-ink/55">
553
+ {m.package.requiredByCms ? (
554
+ <>
555
+ {cms} requires <Mono>{m.package.requiredByCms}</Mono>.{" "}
556
+ </>
557
+ ) : null}
558
+ Update this package only together with a reviewed CMS release.
559
+ </span>
560
+ </>
561
+ )}
562
+ </div>
563
+ </li>
564
+ );
565
+ })}
566
+ </ul>
567
+ </div>
568
+ </section>
569
+ );
570
+ }
571
+
572
+ function PackageRows({ packages }: { packages: readonly PackageReport[] }) {
573
+ return (
574
+ <section aria-labelledby="version-packages" className="mt-12">
575
+ <h3 id="version-packages" className="font-serif text-lg text-ink">
576
+ Installed packages
577
+ </h3>
578
+ <p className="mt-1 text-sm text-ink/60">
579
+ Exact versions in this build, compared with the public npm registry. Release installs are
580
+ compared with stable releases; a prerelease with its own prerelease line.
581
+ </p>
582
+ <ul className="mt-4 grid gap-3" aria-label="Installed packages">
583
+ {packages.map((p) => {
584
+ const note = registryNote(p);
585
+ return (
586
+ <li key={p.name} className={CARD}>
587
+ <div className="flex flex-wrap items-center justify-between gap-3">
588
+ <h4 className="min-w-0 font-mono text-sm text-ink [overflow-wrap:anywhere]">
589
+ {p.name}
590
+ </h4>
591
+ <span className="flex flex-wrap gap-2">
592
+ <StatusBadge status={p.update.status} />
593
+ {p.registry.state === "stale" && (
594
+ <span className={`${PILL} bg-amber-100 text-amber-900`}>Not re-verified</span>
595
+ )}
596
+ </span>
597
+ </div>
598
+ <p className="mt-1 text-xs text-ink/55">
599
+ {p.role === "cms" ? "CMS package" : "Module package"}
600
+ </p>
601
+ <dl className="mt-4 grid gap-4 sm:grid-cols-3">
602
+ <Field label="Installed">
603
+ <PackageVersions pkg={p} />
604
+ </Field>
605
+ <Field label="Declared">
606
+ {p.declared ? <Mono>{p.declared}</Mono> : <span className="text-ink/60">—</span>}
607
+ <Muted>In the site's package manifest</Muted>
608
+ {p.requiredByCms && (
609
+ <Muted>
610
+ CMS requires <Mono>{p.requiredByCms}</Mono>
611
+ </Muted>
612
+ )}
613
+ </Field>
614
+ <Field label="Latest">
615
+ <LatestVersion pkg={p} />
616
+ </Field>
617
+ </dl>
618
+ {note && <p className="mt-3 text-xs text-ink/60">{note}</p>}
619
+ </li>
620
+ );
621
+ })}
622
+ </ul>
623
+ </section>
624
+ );
625
+ }
626
+
627
+ const defaultLink: NonNullable<VersionDeps["renderLink"]> = ({ href, className, children }) => (
628
+ <a href={href} className={className}>
629
+ {children}
630
+ </a>
631
+ );
632
+
633
+ /** Host-agnostic version, package and module screen. */
55
634
  export function VersionView({ deps }: { deps: VersionDeps }) {
56
- const { build, releases, loading, packageName = "@bygghammar/cms-platform" } = deps;
635
+ const { build, releases, loading, packageName = "@bygghammar/cms-platform", report } = deps;
57
636
  const now = useUtcClock();
637
+ const renderLink = deps.renderLink ?? defaultLink;
638
+ const cmsPackage = report?.packages.find((p) => p.role === "cms") ?? null;
639
+ const cmsVersion = cmsPackage?.installed ?? build?.platformVersion ?? null;
640
+ const cmsDeclared = cmsPackage?.declared ?? build?.platformRange ?? null;
641
+ const builtAt = report ? report.builtAt : (build?.buildTime ?? null);
642
+ const history =
643
+ report?.releaseHistory ??
644
+ releases.map((r) => ({ ...r, prerelease: /-/.test(r.version), current: !!r.current }));
58
645
 
59
646
  return (
60
- <div className="p-8 md:p-12 w-full max-w-4xl">
647
+ <div className="w-full max-w-4xl p-6 sm:p-8 md:p-12">
61
648
  <div className="flex items-center gap-3">
62
- <Package className="size-5 text-ink/70" />
63
- <h2 className="font-serif text-xl text-ink">CMS &amp; platform version</h2>
649
+ <Package className="size-5 shrink-0 text-ink/70" />
650
+ <h2 className="font-serif text-xl text-ink">Version &amp; modules</h2>
64
651
  </div>
65
652
  <p className="mt-2 text-sm text-ink/60">
66
- Which build of this site is live and which release of the shared CMS platform package it
67
- runs on.
653
+ Which release of this site is live, which CMS and module packages it runs, and whether
654
+ newer versions are published.
68
655
  </p>
69
656
 
70
657
  {loading ? (
71
- <p className="mt-8 text-sm text-ink/50">Loading…</p>
658
+ <p className="mt-8 text-sm text-ink/50" role="status">
659
+ Loading version information…
660
+ </p>
72
661
  ) : (
73
662
  <>
74
- <dl className="mt-8 grid gap-4 sm:grid-cols-2">
75
- {[
76
- { label: "CMS build", value: build?.appVersion ?? "—" },
77
- { label: "Built at", value: fmt(build?.buildTime) },
78
- { label: "Platform package", value: build?.platformVersion ?? "—" },
79
- { label: "Declared range", value: build?.platformRange ?? "—" },
80
- { label: "Current time", value: now ? formatUtc(now) : "—" },
81
- { label: "Schema baseline", value: SCHEMA_BASELINE || "—" },
82
- {
83
- label: "Canonical migrations",
84
- value: deps.drift
85
- ? `${deps.drift.appliedCount} applied / ${deps.drift.expectedCount} expected`
86
- : String(SCHEMA_MIGRATIONS.length),
87
- },
88
- ].map((row) => (
89
- <div key={row.label} className="border border-ink/10 bg-white p-5">
90
- <dt className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/50">
91
- {row.label}
663
+ <UpdateSummaryBanner
664
+ report={report}
665
+ error={deps.error}
666
+ latestCheckFailed={deps.latestCheckFailed}
667
+ onRefresh={deps.onRefresh}
668
+ refreshing={deps.refreshing}
669
+ />
670
+ {deps.error && deps.onRetry && (
671
+ <button
672
+ type="button"
673
+ onClick={deps.onRetry}
674
+ className="mt-3 inline-flex min-h-10 items-center border border-ink/15 bg-white px-4 py-2 text-sm text-ink hover:border-sea hover:text-sea focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sea"
675
+ >
676
+ Try again
677
+ </button>
678
+ )}
679
+
680
+ <section aria-labelledby="version-release" className="mt-10">
681
+ <h3 id="version-release" className="font-serif text-lg text-ink">
682
+ Release
683
+ </h3>
684
+ <dl className="mt-4 grid gap-4 sm:grid-cols-2">
685
+ <div className={`${CARD} sm:col-span-2`}>
686
+ <dt className={LABEL}>{deps.siteName ? `${deps.siteName} release` : "Site release"}</dt>
687
+ <dd className="mt-2 min-w-0">
688
+ <ReleaseValue report={report} />
689
+ </dd>
690
+ </div>
691
+ <div className={CARD}>
692
+ <dt className={LABEL}>Environment</dt>
693
+ <dd className="mt-2 text-sm text-ink">
694
+ {report?.environment.name ? (
695
+ <Mono>{report.environment.name}</Mono>
696
+ ) : (
697
+ <span className="text-ink/60">Not set</span>
698
+ )}
699
+ {report?.environment.origin && (
700
+ <Muted>
701
+ Serving <Mono>{report.environment.origin}</Mono>
702
+ </Muted>
703
+ )}
704
+ </dd>
705
+ </div>
706
+ <div className={CARD}>
707
+ <dt className={LABEL}>CMS package</dt>
708
+ <dd className="mt-2 text-sm text-ink">
709
+ <Mono>{cmsVersion ?? "Unknown"}</Mono>
710
+ {cmsDeclared && (
711
+ <Muted>
712
+ Declared <Mono>{cmsDeclared}</Mono>
713
+ </Muted>
714
+ )}
715
+ </dd>
716
+ </div>
717
+ <div className={CARD}>
718
+ <dt className={LABEL}>Built at</dt>
719
+ <dd className="mt-2 text-sm text-ink">
720
+ <Mono>{builtAt ? fmt(builtAt) : "Unknown"}</Mono>
721
+ <Muted>When the server bundle was built — not when it went live.</Muted>
722
+ </dd>
723
+ </div>
724
+ <div className={CARD}>
725
+ <dt className={LABEL}>Deployed at</dt>
726
+ <dd className="mt-2 text-sm text-ink">
727
+ {report?.deployedAt ? (
728
+ <Mono>{fmt(report.deployedAt)}</Mono>
729
+ ) : (
730
+ <>
731
+ <span className="text-ink/60">Not recorded</span>
732
+ <Muted>This deployment does not record when it went live.</Muted>
733
+ </>
734
+ )}
735
+ </dd>
736
+ </div>
737
+ <div className={CARD}>
738
+ <dt className={LABEL}>Current time</dt>
739
+ <dd className="mt-2 font-mono text-sm text-ink">{now ? formatUtc(now) : "—"}</dd>
740
+ </div>
741
+ </dl>
742
+ </section>
743
+
744
+ {report && <PackageRows packages={report.packages} />}
745
+
746
+ {deps.modules && (
747
+ <ModuleRows
748
+ modules={deps.modules}
749
+ settingsHref={deps.settingsHref}
750
+ renderLink={renderLink}
751
+ />
752
+ )}
753
+
754
+ <section aria-labelledby="version-schema" className="mt-12">
755
+ <h3 id="version-schema" className="font-serif text-lg text-ink">
756
+ Database schema
757
+ </h3>
758
+ <dl className="mt-4 grid gap-4 sm:grid-cols-2">
759
+ <div className={CARD}>
760
+ <dt className={LABEL}>Packaged schema baseline</dt>
761
+ <dd className="mt-2 font-mono text-sm text-ink [overflow-wrap:anywhere]">
762
+ {SCHEMA_BASELINE || "—"}
763
+ </dd>
764
+ </div>
765
+ <div className={CARD}>
766
+ <dt className={LABEL}>
767
+ {deps.drift ? "Canonical migrations" : "Canonical migrations in package"}
92
768
  </dt>
93
- <dd className="mt-2 font-mono text-sm text-ink">{row.value}</dd>
769
+ <dd className="mt-2 font-mono text-sm text-ink">
770
+ {deps.drift
771
+ ? `${deps.drift.appliedCount} applied / ${deps.drift.expectedCount} expected`
772
+ : String(SCHEMA_MIGRATIONS.length)}
773
+ </dd>
94
774
  </div>
95
- ))}
96
- </dl>
775
+ </dl>
776
+ {!deps.drift && (
777
+ <p className="mt-2 text-xs text-ink/55">
778
+ From the installed CMS package. This is not a check of the live database.
779
+ </p>
780
+ )}
781
+ </section>
97
782
 
98
783
  {deps.drift && !deps.drift.inSync && (
99
784
  <div className="mt-8 border border-amber-300 bg-amber-50 p-5 text-sm">
@@ -115,42 +800,38 @@ export function VersionView({ deps }: { deps: VersionDeps }) {
115
800
  </div>
116
801
  )}
117
802
 
118
- <h3 className="mt-12 font-serif text-lg text-ink">Latest platform releases</h3>
119
- <p className="mt-1 text-sm text-ink/60">
120
- The three most recent published versions of {packageName} with their publish dates.
121
- </p>
122
- <div className="mt-4 border border-ink/10 bg-white">
123
- {releases.length === 0 ? (
124
- <p className="p-5 text-sm text-ink/50">Release history unavailable right now.</p>
125
- ) : (
126
- <table className="w-full text-sm">
127
- <thead>
128
- <tr className="border-b border-ink/10 text-left text-[0.7rem] uppercase tracking-[0.18em] text-ink/50">
129
- <th className="px-5 py-3">Version</th>
130
- <th className="px-5 py-3">Published</th>
131
- <th className="px-5 py-3">Status</th>
132
- </tr>
133
- </thead>
134
- <tbody>
135
- {releases.map((r) => (
136
- <tr key={r.version} className="border-b border-ink/5 last:border-0">
137
- <td className="px-5 py-3 font-mono text-ink">{r.version}</td>
138
- <td className="px-5 py-3 text-ink/70">{fmt(r.publishedAt)}</td>
139
- <td className="px-5 py-3">
140
- {r.current ? (
141
- <span className="inline-flex items-center rounded-full bg-sea/10 px-3 py-1 text-[0.7rem] uppercase tracking-[0.16em] text-sea">
142
- In use
143
- </span>
144
- ) : (
145
- <span className="text-ink/40">—</span>
146
- )}
147
- </td>
148
- </tr>
803
+ <section aria-labelledby="version-history" className="mt-12">
804
+ <h3 id="version-history" className="font-serif text-lg text-ink">
805
+ Recent CMS releases
806
+ </h3>
807
+ <p className="mt-1 text-sm text-ink/60">
808
+ The most recent publications of <Mono>{packageName}</Mono>, newest first, with their
809
+ publish dates.
810
+ </p>
811
+ <div className="mt-4 border border-ink/10 bg-white">
812
+ {history.length === 0 ? (
813
+ <p className="p-5 text-sm text-ink/50">Release history unavailable right now.</p>
814
+ ) : (
815
+ <ul aria-label="Recent CMS releases">
816
+ {history.map((r) => (
817
+ <li
818
+ key={r.version}
819
+ className="flex flex-wrap items-center gap-x-4 gap-y-1 border-b border-ink/5 px-5 py-3 text-sm last:border-0"
820
+ >
821
+ <span className="min-w-[7rem] font-mono text-ink [overflow-wrap:anywhere]">
822
+ {r.version}
823
+ </span>
824
+ <span className="text-ink/70">{fmt(r.publishedAt)}</span>
825
+ <span className="flex flex-wrap gap-2 sm:ml-auto">
826
+ {r.prerelease && <span className={`${PILL} bg-ink/5 text-ink/60`}>Prerelease</span>}
827
+ {r.current && <span className={`${PILL} bg-sea/10 text-sea`}>In use</span>}
828
+ </span>
829
+ </li>
149
830
  ))}
150
- </tbody>
151
- </table>
152
- )}
153
- </div>
831
+ </ul>
832
+ )}
833
+ </div>
834
+ </section>
154
835
  </>
155
836
  )}
156
837
  </div>