@bygghammar/cms-platform 0.90.0-m7.9 → 0.91.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +241 -0
- package/README.md +23 -1
- package/docs/GOOGLE-SHEETS.md +145 -0
- package/docs/LAYOUT-BLOCKS.md +125 -0
- package/docs/MEDIA-FOLDERS.md +90 -0
- package/docs/NEW-SITE.md +35 -0
- package/docs/PAGES.md +452 -0
- package/docs/SCHEDULING.md +130 -0
- package/docs/TYPOGRAPHY.md +86 -0
- package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
- package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
- package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
- package/package.json +5 -1
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/FormEditorView.tsx +10 -0
- package/src/backoffice/FormSettingsView.tsx +8 -0
- package/src/backoffice/MediaLibraryView.tsx +239 -21
- package/src/backoffice/NavigationView.tsx +43 -0
- package/src/backoffice/PageBuilderView.tsx +193 -36
- package/src/backoffice/PageEditorView.tsx +293 -57
- package/src/backoffice/PagesListView.tsx +294 -19
- package/src/backoffice/ScheduleFields.tsx +151 -0
- package/src/backoffice/SectionFormView.tsx +58 -0
- package/src/backoffice/StatusBadge.tsx +174 -0
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/TypographyPanel.tsx +308 -0
- package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
- package/src/backoffice/inspectors/blocks.tsx +149 -0
- package/src/backoffice/inspectors/fields.tsx +81 -0
- package/src/backoffice/inspectors/index.tsx +3 -0
- package/src/backoffice/media/ConfirmDialog.tsx +64 -4
- package/src/backoffice/routes/host.ts +31 -0
- package/src/backoffice/routes/index.tsx +140 -15
- package/src/backoffice/routes/media-forms.tsx +5 -0
- package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
- package/src/backoffice/seo/page-seo-form.ts +11 -1
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/block-children.tsx +40 -0
- package/src/blocks/block-tree.ts +378 -0
- package/src/blocks/components/IconGridBlock.tsx +76 -51
- package/src/blocks/components/LayoutBlock.tsx +100 -0
- package/src/blocks/countdown-time.ts +17 -87
- package/src/blocks/registry.tsx +21 -0
- package/src/blocks/types.ts +40 -0
- package/src/cms/page-publication.ts +377 -0
- package/src/cms/page-status.ts +152 -0
- package/src/cms/timezone.ts +181 -0
- package/src/cms/use-publication-boundary.ts +84 -0
- package/src/cms/use-schedule-clock.ts +43 -0
- package/src/cms/visibility-schedule.ts +267 -0
- package/src/config/modules.ts +34 -10
- package/src/config/site-config.ts +32 -0
- package/src/config/typography.ts +143 -0
- package/src/http/cache-policy.ts +49 -2
- package/src/media/folders.ts +183 -0
- package/src/media/use-media-upload.ts +21 -4
- package/src/navigation/index.ts +34 -2
- package/src/platform/contract.ts +52 -1
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +16 -0
- package/src/server/domains-core.ts +124 -107
- package/src/server/forms-delivery-core.ts +61 -10
- package/src/server/forms-submissions-core.ts +47 -0
- package/src/server/google-service-account.ts +325 -0
- package/src/server/google-sheets-core.ts +463 -0
- package/src/server/http/sitemap.ts +32 -8
- package/src/server/injections-core.ts +230 -49
- package/src/server/media-library-core.ts +27 -6
- package/src/server/pages-core.ts +680 -184
- package/src/server/published-cache.ts +25 -4
- package/src/server/resolve-globals.ts +6 -1
- package/src/server/schema-drift.ts +4 -0
- package/src/server/schemas.ts +15 -0
- package/src/site/PageRenderer.tsx +45 -12
- package/src/site/SiteFooter.tsx +44 -3
- package/src/site/SiteHeader.tsx +23 -2
- package/src/site/google-fonts.ts +191 -0
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes/public-page.tsx +12 -1
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/site/typography.tsx +75 -0
- package/src/tracking/injections.ts +52 -9
- package/src/ui/MobileCarousel.tsx +289 -0
|
@@ -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 = {
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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(
|
|
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(
|
|
235
|
-
unpublishAt:
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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}
|
|
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" />
|
|
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
|
|
468
|
-
time. The site auto-selects the
|
|
610
|
+
Times are in the site’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
|
-
{
|
|
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
|
|
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
|
})}
|