@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.
- package/CHANGELOG.md +93 -0
- package/package.json +2 -1
- package/src/admin/module-inventory.ts +67 -0
- package/src/admin/registry-metadata.ts +256 -0
- package/src/admin/semver.ts +111 -0
- package/src/admin/version-core.ts +233 -25
- package/src/admin/version-report.ts +295 -0
- package/src/backoffice/VersionView.tsx +747 -66
- package/src/backoffice/media/ReuploadOverwrite.tsx +2 -2
- package/src/backoffice/media/ReviewImageUpload.tsx +8 -4
- package/src/backoffice/routes/index.tsx +94 -9
- package/src/backoffice/shell/use-resolved-config.tsx +30 -5
- package/src/config/modules.ts +11 -0
- package/src/media/FocalImage.tsx +12 -16
- package/src/media/optimize.ts +8 -6
- package/src/media/review-image.ts +22 -8
- package/src/media/srcset.ts +13 -6
- package/src/popups/package-name.ts +8 -0
- package/src/server/media-library-core.ts +5 -3
- package/src/server/review-image-validate.ts +3 -2
|
@@ -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
|
-
|
|
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
|
|
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">
|
|
649
|
+
<Package className="size-5 shrink-0 text-ink/70" />
|
|
650
|
+
<h2 className="font-serif text-xl text-ink">Version & modules</h2>
|
|
64
651
|
</div>
|
|
65
652
|
<p className="mt-2 text-sm text-ink/60">
|
|
66
|
-
Which
|
|
67
|
-
|
|
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">
|
|
658
|
+
<p className="mt-8 text-sm text-ink/50" role="status">
|
|
659
|
+
Loading version information…
|
|
660
|
+
</p>
|
|
72
661
|
) : (
|
|
73
662
|
<>
|
|
74
|
-
<
|
|
75
|
-
{
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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">
|
|
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
|
-
|
|
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
|
-
<
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
<
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
</
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
</
|
|
831
|
+
</ul>
|
|
832
|
+
)}
|
|
833
|
+
</div>
|
|
834
|
+
</section>
|
|
154
835
|
</>
|
|
155
836
|
)}
|
|
156
837
|
</div>
|