@bygghammar/cms-platform 0.94.0 → 0.95.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 +74 -0
- package/docs/PAGES.md +72 -4
- package/package.json +8 -1
- package/src/admin/describe-log.ts +3 -0
- package/src/backoffice/BookingSettingsCard.tsx +252 -0
- package/src/backoffice/NavigationView.tsx +38 -0
- package/src/backoffice/PageBuilderView.tsx +149 -3
- package/src/backoffice/PagesListView.tsx +173 -57
- package/src/backoffice/SiteSettingsView.tsx +4 -0
- package/src/backoffice/StatusBadge.tsx +38 -2
- package/src/backoffice/media/ConfirmDialog.tsx +19 -1
- package/src/backoffice/routes/host.ts +24 -0
- package/src/backoffice/routes/index.tsx +54 -0
- package/src/backoffice/routes/pages.tsx +13 -0
- package/src/cms/booking-links.ts +176 -0
- package/src/cms/page-publication.ts +11 -6
- package/src/cms/page-visibility.ts +206 -0
- package/src/html/sanitize-html.ts +1 -1
- package/src/navigation/visibility.ts +235 -0
- package/src/popups/runtime.tsx +155 -4
- package/src/server/page-visibility-core.ts +450 -0
- package/src/server/pages-core.ts +238 -9
- package/src/server/published-cache.ts +23 -3
- package/src/server/schemas.ts +17 -0
- package/src/site/PageRenderer.tsx +34 -5
- package/src/site/SiteFooter.tsx +11 -2
- package/src/site/SiteHeader.tsx +41 -21
- package/src/site/link-visibility.tsx +28 -0
- package/src/site/route-aliases.ts +74 -0
- package/src/site/routes/preview-page.tsx +39 -3
- package/src/site/routes/public-page.tsx +81 -9
|
@@ -20,7 +20,16 @@ import { usePublicationBoundaryRefresh } from "../cms/use-publication-boundary";
|
|
|
20
20
|
import { zoneAbbreviation, zonedLocalExists, zonedLocalToInstant } from "../cms/timezone";
|
|
21
21
|
import { siteTimezone } from "../config/site-config";
|
|
22
22
|
import { EditLockBanner, EditLockFence, useEditLock } from "./EditLock";
|
|
23
|
-
import { PageStatusBadge, PublicationBadge } from "./StatusBadge";
|
|
23
|
+
import { ListingBadge, PageStatusBadge, ProtectedBadge, PublicationBadge } from "./StatusBadge";
|
|
24
|
+
import { ConfirmDialog } from "./media/ConfirmDialog";
|
|
25
|
+
import {
|
|
26
|
+
linkOnlyConfirmation,
|
|
27
|
+
normalizePageListing,
|
|
28
|
+
PROTECTED_PAGE_REASON,
|
|
29
|
+
showOnWebsiteConfirmation,
|
|
30
|
+
unpublishConsequences,
|
|
31
|
+
type PageListing,
|
|
32
|
+
} from "../cms/page-visibility";
|
|
24
33
|
import {
|
|
25
34
|
ArrowLeft,
|
|
26
35
|
Save,
|
|
@@ -32,6 +41,9 @@ import {
|
|
|
32
41
|
ExternalLink,
|
|
33
42
|
Copy,
|
|
34
43
|
Check,
|
|
44
|
+
EyeOff,
|
|
45
|
+
Link2,
|
|
46
|
+
ListChecks,
|
|
35
47
|
} from "lucide-react";
|
|
36
48
|
|
|
37
49
|
/** A page row as the builder needs it. */
|
|
@@ -57,6 +69,12 @@ export type BuilderPage = {
|
|
|
57
69
|
publication_transition?: PublicationTransitionKind | null;
|
|
58
70
|
/** The version showing after that, for a publish or a replacement. */
|
|
59
71
|
publication_next_version_id?: string | null;
|
|
72
|
+
/** Listed (default) or link-only; independent of publication. */
|
|
73
|
+
listing?: PageListing;
|
|
74
|
+
/** Version token sent back with a listing change; null when never changed. */
|
|
75
|
+
listing_version?: string | null;
|
|
76
|
+
/** The start and contact pages: never unpublished, never unlisted. */
|
|
77
|
+
protected?: boolean;
|
|
60
78
|
};
|
|
61
79
|
|
|
62
80
|
/** One saved version, with its computed change summary. */
|
|
@@ -143,6 +161,22 @@ export type PageBuilderDeps<TBlock> = {
|
|
|
143
161
|
cancelPublication: (publicationId: string, editSessionId?: string) => Promise<unknown>;
|
|
144
162
|
restoreVersion: (versionId: string, editSessionId?: string) => Promise<unknown>;
|
|
145
163
|
rename: (input: { pageId: string; name: string; editSessionId?: string }) => Promise<unknown>;
|
|
164
|
+
/**
|
|
165
|
+
* Take the page offline, behind a confirmation. Optional: without it the
|
|
166
|
+
* header offers no Unpublish.
|
|
167
|
+
*/
|
|
168
|
+
setPageStatus?: (input: {
|
|
169
|
+
pageId: string;
|
|
170
|
+
status: "draft" | "published";
|
|
171
|
+
editSessionId?: string;
|
|
172
|
+
}) => Promise<unknown>;
|
|
173
|
+
/** Make the page link-only or list it again. Optional likewise. */
|
|
174
|
+
setPageListing?: (input: {
|
|
175
|
+
pageId: string;
|
|
176
|
+
listing: PageListing;
|
|
177
|
+
expectedVersion: string | null;
|
|
178
|
+
editSessionId?: string;
|
|
179
|
+
}) => Promise<unknown>;
|
|
146
180
|
refetchPage: () => void;
|
|
147
181
|
refetchVersions: () => void;
|
|
148
182
|
refetchPublications: () => void;
|
|
@@ -192,6 +226,9 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
|
|
|
192
226
|
const [renameValue, setRenameValue] = useState<string | null>(null);
|
|
193
227
|
const [locale, setLocale] = useState<string>(defaultLocale);
|
|
194
228
|
const [copied, setCopied] = useState(false);
|
|
229
|
+
const [visibilityDialog, setVisibilityDialog] = useState<
|
|
230
|
+
null | { kind: "unpublish" } | { kind: "listing"; next: PageListing }
|
|
231
|
+
>(null);
|
|
195
232
|
|
|
196
233
|
// Held for as long as this builder is open. Without a lock provider the
|
|
197
234
|
// handle reports "not enforced" and nothing below changes.
|
|
@@ -391,6 +428,43 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
|
|
|
391
428
|
}
|
|
392
429
|
};
|
|
393
430
|
|
|
431
|
+
/** Unpublish and listing changes, once their dialog is confirmed. */
|
|
432
|
+
const onConfirmVisibility = async () => {
|
|
433
|
+
if (!page || !visibilityDialog) return;
|
|
434
|
+
setBusy(true);
|
|
435
|
+
try {
|
|
436
|
+
if (visibilityDialog.kind === "unpublish") {
|
|
437
|
+
if (!deps.setPageStatus) return;
|
|
438
|
+
await deps.setPageStatus({
|
|
439
|
+
pageId: page.id,
|
|
440
|
+
status: "draft",
|
|
441
|
+
editSessionId: lock.sessionId,
|
|
442
|
+
});
|
|
443
|
+
notify.success(`"${page.name}" is offline`);
|
|
444
|
+
} else {
|
|
445
|
+
if (!deps.setPageListing) return;
|
|
446
|
+
await deps.setPageListing({
|
|
447
|
+
pageId: page.id,
|
|
448
|
+
listing: visibilityDialog.next,
|
|
449
|
+
expectedVersion: page.listing_version ?? null,
|
|
450
|
+
editSessionId: lock.sessionId,
|
|
451
|
+
});
|
|
452
|
+
notify.success(
|
|
453
|
+
visibilityDialog.next === "link-only"
|
|
454
|
+
? `"${page.name}" is now link-only`
|
|
455
|
+
: `"${page.name}" is listed on the website again`,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
setVisibilityDialog(null);
|
|
459
|
+
} catch (e) {
|
|
460
|
+
notify.error(e instanceof Error ? e.message : "Failed");
|
|
461
|
+
} finally {
|
|
462
|
+
setBusy(false);
|
|
463
|
+
deps.refetchPage();
|
|
464
|
+
deps.refetchPublications();
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
|
|
394
468
|
if (isLoading) return <div className="p-12 text-ink/60">Loading…</div>;
|
|
395
469
|
if (error) return <div className="p-12 text-red-600">Failed to load page.</div>;
|
|
396
470
|
if (!page) return null;
|
|
@@ -505,7 +579,38 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
|
|
|
505
579
|
at={page.publication_at}
|
|
506
580
|
transition={page.publication_transition}
|
|
507
581
|
/>
|
|
582
|
+
<ListingBadge listing={normalizePageListing(page.listing)} publication={page.publication} />
|
|
508
583
|
<PageStatusBadge status={normalizePageStatus(page.status)} />
|
|
584
|
+
{page.protected ? <ProtectedBadge /> : null}
|
|
585
|
+
{/* What a visitor gets, changed from where it is shown. */}
|
|
586
|
+
{!page.protected && deps.setPageStatus && isPubliclyVisible(page.publication) ? (
|
|
587
|
+
<button
|
|
588
|
+
onClick={() => setVisibilityDialog({ kind: "unpublish" })}
|
|
589
|
+
disabled={busy || lock.blocked}
|
|
590
|
+
className="inline-flex items-center gap-1 border border-ink/20 px-2 py-0.5 text-xs text-ink/70 hover:border-ink hover:text-ink disabled:opacity-40"
|
|
591
|
+
>
|
|
592
|
+
<EyeOff className="size-3" aria-hidden="true" /> Unpublish
|
|
593
|
+
</button>
|
|
594
|
+
) : null}
|
|
595
|
+
{!page.protected && deps.setPageListing ? (
|
|
596
|
+
normalizePageListing(page.listing) === "link-only" ? (
|
|
597
|
+
<button
|
|
598
|
+
onClick={() => setVisibilityDialog({ kind: "listing", next: "listed" })}
|
|
599
|
+
disabled={busy || lock.blocked}
|
|
600
|
+
className="inline-flex items-center gap-1 border border-ink/20 px-2 py-0.5 text-xs text-ink/70 hover:border-ink hover:text-ink disabled:opacity-40"
|
|
601
|
+
>
|
|
602
|
+
<ListChecks className="size-3" aria-hidden="true" /> Show on website
|
|
603
|
+
</button>
|
|
604
|
+
) : (
|
|
605
|
+
<button
|
|
606
|
+
onClick={() => setVisibilityDialog({ kind: "listing", next: "link-only" })}
|
|
607
|
+
disabled={busy || lock.blocked}
|
|
608
|
+
className="inline-flex items-center gap-1 border border-ink/20 px-2 py-0.5 text-xs text-ink/70 hover:border-ink hover:text-ink disabled:opacity-40"
|
|
609
|
+
>
|
|
610
|
+
<Link2 className="size-3" aria-hidden="true" /> Make link-only
|
|
611
|
+
</button>
|
|
612
|
+
)
|
|
613
|
+
) : null}
|
|
509
614
|
</div>
|
|
510
615
|
<div className="text-xs text-ink/50 font-mono mt-2">
|
|
511
616
|
{page.key}
|
|
@@ -580,10 +685,11 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
|
|
|
580
685
|
</span>
|
|
581
686
|
<input
|
|
582
687
|
type="datetime-local"
|
|
583
|
-
disabled={lock.blocked}
|
|
688
|
+
disabled={lock.blocked || page.protected === true}
|
|
584
689
|
value={unpublishAt}
|
|
585
690
|
onChange={(e) => setUnpublishAt(e.target.value)}
|
|
586
|
-
|
|
691
|
+
title={page.protected ? PROTECTED_PAGE_REASON : undefined}
|
|
692
|
+
className="w-full border border-ink/20 px-2 py-1.5 text-sm disabled:opacity-40"
|
|
587
693
|
/>
|
|
588
694
|
</label>
|
|
589
695
|
<label className="block md:col-span-1">
|
|
@@ -774,6 +880,46 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
|
|
|
774
880
|
)}
|
|
775
881
|
|
|
776
882
|
{deps.footer && <div className="mt-6 text-xs text-ink/50">{deps.footer}</div>}
|
|
883
|
+
|
|
884
|
+
{visibilityDialog?.kind === "unpublish" ? (
|
|
885
|
+
<ConfirmDialog
|
|
886
|
+
title={`Unpublish “${page.name}”?`}
|
|
887
|
+
description={statusChangeConfirmation("draft", page.publication, page.publication_transition)}
|
|
888
|
+
consequences={unpublishConsequences({
|
|
889
|
+
path: slugPath,
|
|
890
|
+
listing: normalizePageListing(page.listing),
|
|
891
|
+
})}
|
|
892
|
+
confirmLabel="Unpublish"
|
|
893
|
+
danger
|
|
894
|
+
busy={busy}
|
|
895
|
+
onConfirm={onConfirmVisibility}
|
|
896
|
+
onCancel={() => setVisibilityDialog(null)}
|
|
897
|
+
/>
|
|
898
|
+
) : null}
|
|
899
|
+
{visibilityDialog?.kind === "listing"
|
|
900
|
+
? (() => {
|
|
901
|
+
const facts = {
|
|
902
|
+
name: page.name,
|
|
903
|
+
path: slugPath,
|
|
904
|
+
live: isPubliclyVisible(page.publication),
|
|
905
|
+
};
|
|
906
|
+
const copy =
|
|
907
|
+
visibilityDialog.next === "link-only"
|
|
908
|
+
? linkOnlyConfirmation(facts)
|
|
909
|
+
: showOnWebsiteConfirmation(facts);
|
|
910
|
+
return (
|
|
911
|
+
<ConfirmDialog
|
|
912
|
+
title={copy.title}
|
|
913
|
+
description={copy.body[0]}
|
|
914
|
+
consequences={copy.body.slice(1)}
|
|
915
|
+
confirmLabel={copy.confirmLabel}
|
|
916
|
+
busy={busy}
|
|
917
|
+
onConfirm={onConfirmVisibility}
|
|
918
|
+
onCancel={() => setVisibilityDialog(null)}
|
|
919
|
+
/>
|
|
920
|
+
);
|
|
921
|
+
})()
|
|
922
|
+
: null}
|
|
777
923
|
</div>
|
|
778
924
|
);
|
|
779
925
|
}
|
|
@@ -10,9 +10,11 @@ import {
|
|
|
10
10
|
Copy,
|
|
11
11
|
Eye,
|
|
12
12
|
EyeOff,
|
|
13
|
+
Link2,
|
|
14
|
+
ListChecks,
|
|
13
15
|
} from "lucide-react";
|
|
14
16
|
import { ConfirmDialog } from "./media/ConfirmDialog";
|
|
15
|
-
import { PageStatusBadge, PublicationBadge } from "./StatusBadge";
|
|
17
|
+
import { ListingBadge, PageStatusBadge, ProtectedBadge, PublicationBadge } from "./StatusBadge";
|
|
16
18
|
import {
|
|
17
19
|
duplicateNameFor,
|
|
18
20
|
isValidPageKey,
|
|
@@ -28,6 +30,13 @@ import {
|
|
|
28
30
|
type PublicationTransitionKind,
|
|
29
31
|
} from "../cms/page-publication";
|
|
30
32
|
import { usePublicationBoundaryRefresh } from "../cms/use-publication-boundary";
|
|
33
|
+
import {
|
|
34
|
+
linkOnlyConfirmation,
|
|
35
|
+
normalizePageListing,
|
|
36
|
+
showOnWebsiteConfirmation,
|
|
37
|
+
unpublishConsequences,
|
|
38
|
+
type PageListing,
|
|
39
|
+
} from "../cms/page-visibility";
|
|
31
40
|
|
|
32
41
|
/** A page row as listed by the host. */
|
|
33
42
|
export type PageListRow = {
|
|
@@ -56,6 +65,12 @@ export type PageListRow = {
|
|
|
56
65
|
publication_transition?: PublicationTransitionKind | null;
|
|
57
66
|
updated_at: string;
|
|
58
67
|
is_system?: boolean | null;
|
|
68
|
+
/** Listed (default) or link-only; independent of publication. */
|
|
69
|
+
listing?: PageListing;
|
|
70
|
+
/** Version token sent back with a listing change; null when never changed. */
|
|
71
|
+
listing_version?: string | null;
|
|
72
|
+
/** The start and contact pages: never unpublished, never unlisted. */
|
|
73
|
+
protected?: boolean;
|
|
59
74
|
};
|
|
60
75
|
|
|
61
76
|
/** A soft-deleted page kept in the trash bin. */
|
|
@@ -86,6 +101,15 @@ export type PagesListDeps = {
|
|
|
86
101
|
* has not wired it up simply does not show the action.
|
|
87
102
|
*/
|
|
88
103
|
setPageStatus?: (input: { pageId: string; status: PageStatus }) => Promise<unknown>;
|
|
104
|
+
/**
|
|
105
|
+
* Make a page link-only or list it again — listing only, never a publish.
|
|
106
|
+
* Optional: without it the actions are not offered.
|
|
107
|
+
*/
|
|
108
|
+
setPageListing?: (input: {
|
|
109
|
+
pageId: string;
|
|
110
|
+
listing: PageListing;
|
|
111
|
+
expectedVersion: string | null;
|
|
112
|
+
}) => Promise<unknown>;
|
|
89
113
|
/** Copy a page into a new draft. Optional for the same reason. */
|
|
90
114
|
duplicatePage?: (input: {
|
|
91
115
|
pageId: string;
|
|
@@ -114,6 +138,7 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
|
|
|
114
138
|
createPage,
|
|
115
139
|
renamePage,
|
|
116
140
|
setPageStatus,
|
|
141
|
+
setPageListing,
|
|
117
142
|
duplicatePage,
|
|
118
143
|
deletePage,
|
|
119
144
|
restorePage,
|
|
@@ -132,6 +157,10 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
|
|
|
132
157
|
page: PageListRow;
|
|
133
158
|
next: PageStatus;
|
|
134
159
|
} | null>(null);
|
|
160
|
+
const [listingChange, setListingChange] = useState<{
|
|
161
|
+
page: PageListRow;
|
|
162
|
+
next: PageListing;
|
|
163
|
+
} | null>(null);
|
|
135
164
|
const [duplicating, setDuplicating] = useState<{
|
|
136
165
|
page: PageListRow;
|
|
137
166
|
name: string;
|
|
@@ -202,6 +231,32 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
|
|
|
202
231
|
}
|
|
203
232
|
};
|
|
204
233
|
|
|
234
|
+
/** Listing changes are confirmed too, and never touch publication. */
|
|
235
|
+
const onConfirmListing = async () => {
|
|
236
|
+
if (!listingChange || !setPageListing) return;
|
|
237
|
+
setBusy(true);
|
|
238
|
+
try {
|
|
239
|
+
await setPageListing({
|
|
240
|
+
pageId: listingChange.page.id,
|
|
241
|
+
listing: listingChange.next,
|
|
242
|
+
expectedVersion: listingChange.page.listing_version ?? null,
|
|
243
|
+
});
|
|
244
|
+
toast.success(
|
|
245
|
+
listingChange.next === "link-only"
|
|
246
|
+
? `"${listingChange.page.name}" is now link-only`
|
|
247
|
+
: `"${listingChange.page.name}" is listed on the website again`,
|
|
248
|
+
);
|
|
249
|
+
setListingChange(null);
|
|
250
|
+
refetchPages();
|
|
251
|
+
} catch (e) {
|
|
252
|
+
fail(e);
|
|
253
|
+
// A conflict means the row changed under us: show what is true now.
|
|
254
|
+
refetchPages();
|
|
255
|
+
} finally {
|
|
256
|
+
setBusy(false);
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
205
260
|
const onConfirmDuplicate = async () => {
|
|
206
261
|
if (!duplicating || !duplicatePage || duplicateProblem) return;
|
|
207
262
|
setBusy(true);
|
|
@@ -323,8 +378,10 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
|
|
|
323
378
|
that is not public stays editable and previewable by signed-in staff.
|
|
324
379
|
</p>
|
|
325
380
|
|
|
326
|
-
|
|
327
|
-
|
|
381
|
+
{/* Six columns do not fit a phone: the table scrolls inside its frame
|
|
382
|
+
rather than widening the page. */}
|
|
383
|
+
<div className="mt-8 border border-ink/10 bg-white overflow-x-auto">
|
|
384
|
+
<table className="w-full min-w-[56rem] text-sm">
|
|
328
385
|
<thead className="bg-ink/[0.03] text-left text-[0.7rem] uppercase tracking-[0.18em] text-ink/60">
|
|
329
386
|
<tr>
|
|
330
387
|
<th className="px-4 py-3">Name</th>
|
|
@@ -396,7 +453,12 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
|
|
|
396
453
|
at={p.publication_at}
|
|
397
454
|
transition={p.publication_transition}
|
|
398
455
|
/>
|
|
456
|
+
<ListingBadge
|
|
457
|
+
listing={normalizePageListing(p.listing)}
|
|
458
|
+
publication={p.publication}
|
|
459
|
+
/>
|
|
399
460
|
<PageStatusBadge status={normalizePageStatus(p.status)} />
|
|
461
|
+
{p.protected ? <ProtectedBadge /> : null}
|
|
400
462
|
</div>
|
|
401
463
|
</td>
|
|
402
464
|
<td className="px-4 py-3 text-ink/50">{new Date(p.updated_at).toLocaleString()}</td>
|
|
@@ -410,18 +472,37 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
|
|
|
410
472
|
</button>
|
|
411
473
|
{setPageStatus ? (
|
|
412
474
|
isPubliclyVisible(p.publication) ? (
|
|
475
|
+
p.protected ? null : (
|
|
476
|
+
<button
|
|
477
|
+
onClick={() => setStatusChange({ page: p, next: "draft" })}
|
|
478
|
+
className="inline-flex items-center gap-1 text-ink/60 hover:text-ink hover:underline"
|
|
479
|
+
>
|
|
480
|
+
<EyeOff className="size-3.5" /> Unpublish
|
|
481
|
+
</button>
|
|
482
|
+
)
|
|
483
|
+
) : (
|
|
413
484
|
<button
|
|
414
|
-
onClick={() => setStatusChange({ page: p, next: "
|
|
485
|
+
onClick={() => setStatusChange({ page: p, next: "published" })}
|
|
415
486
|
className="inline-flex items-center gap-1 text-ink/60 hover:text-ink hover:underline"
|
|
416
487
|
>
|
|
417
|
-
<
|
|
488
|
+
<Eye className="size-3.5" /> Publish
|
|
489
|
+
</button>
|
|
490
|
+
)
|
|
491
|
+
) : null}
|
|
492
|
+
{setPageListing && !p.protected ? (
|
|
493
|
+
normalizePageListing(p.listing) === "link-only" ? (
|
|
494
|
+
<button
|
|
495
|
+
onClick={() => setListingChange({ page: p, next: "listed" })}
|
|
496
|
+
className="inline-flex items-center gap-1 text-ink/60 hover:text-ink hover:underline"
|
|
497
|
+
>
|
|
498
|
+
<ListChecks className="size-3.5" /> Show on website
|
|
418
499
|
</button>
|
|
419
500
|
) : (
|
|
420
501
|
<button
|
|
421
|
-
onClick={() =>
|
|
502
|
+
onClick={() => setListingChange({ page: p, next: "link-only" })}
|
|
422
503
|
className="inline-flex items-center gap-1 text-ink/60 hover:text-ink hover:underline"
|
|
423
504
|
>
|
|
424
|
-
<
|
|
505
|
+
<Link2 className="size-3.5" /> Make link-only
|
|
425
506
|
</button>
|
|
426
507
|
)
|
|
427
508
|
) : null}
|
|
@@ -440,7 +521,7 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
|
|
|
440
521
|
<Copy className="size-3.5" /> Duplicate
|
|
441
522
|
</button>
|
|
442
523
|
) : null}
|
|
443
|
-
{!p.is_system && (
|
|
524
|
+
{!p.is_system && !p.protected && (
|
|
444
525
|
<button
|
|
445
526
|
onClick={() => onDelete(p.id, p.name)}
|
|
446
527
|
className="inline-flex items-center gap-1 text-red-600 hover:underline"
|
|
@@ -473,58 +554,60 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
|
|
|
473
554
|
</p>
|
|
474
555
|
</div>
|
|
475
556
|
</div>
|
|
476
|
-
<
|
|
477
|
-
<
|
|
478
|
-
<
|
|
479
|
-
<
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
<
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
<
|
|
499
|
-
onClick={() => onRestore(p.id)}
|
|
500
|
-
className="inline-flex items-center gap-1 text-sea hover:underline mr-3"
|
|
501
|
-
>
|
|
502
|
-
<Undo2 className="size-3.5" /> Restore
|
|
503
|
-
</button>
|
|
504
|
-
{trash?.canPurge ? (
|
|
557
|
+
<div className="overflow-x-auto">
|
|
558
|
+
<table className="w-full min-w-[40rem] text-sm">
|
|
559
|
+
<thead className="bg-ink/[0.03] text-left text-[0.7rem] uppercase tracking-[0.18em] text-ink/60">
|
|
560
|
+
<tr>
|
|
561
|
+
<th className="px-4 py-3">Name</th>
|
|
562
|
+
<th className="px-4 py-3">Key</th>
|
|
563
|
+
<th className="px-4 py-3">Deleted by</th>
|
|
564
|
+
<th className="px-4 py-3">Deleted at</th>
|
|
565
|
+
<th className="px-4 py-3 w-56"></th>
|
|
566
|
+
</tr>
|
|
567
|
+
</thead>
|
|
568
|
+
<tbody>
|
|
569
|
+
{trash?.rows.map((p) => (
|
|
570
|
+
<tr key={p.id} className="border-t border-ink/10">
|
|
571
|
+
<td className="px-4 py-3 text-ink">{p.name}</td>
|
|
572
|
+
<td className="px-4 py-3 font-mono text-ink/70">{p.key}</td>
|
|
573
|
+
<td className="px-4 py-3 text-ink/70">
|
|
574
|
+
{p.deleted_by_email ?? p.deleted_by ?? "Unknown user"}
|
|
575
|
+
</td>
|
|
576
|
+
<td className="px-4 py-3 text-ink/50">
|
|
577
|
+
{p.deleted_at ? new Date(p.deleted_at).toLocaleString() : "—"}
|
|
578
|
+
</td>
|
|
579
|
+
<td className="px-4 py-3 text-right">
|
|
505
580
|
<button
|
|
506
|
-
onClick={() =>
|
|
507
|
-
className="inline-flex items-center gap-1 text-
|
|
581
|
+
onClick={() => onRestore(p.id)}
|
|
582
|
+
className="inline-flex items-center gap-1 text-sea hover:underline mr-3"
|
|
508
583
|
>
|
|
509
|
-
<
|
|
584
|
+
<Undo2 className="size-3.5" /> Restore
|
|
510
585
|
</button>
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
586
|
+
{trash?.canPurge ? (
|
|
587
|
+
<button
|
|
588
|
+
onClick={() => onPurge(p.id, p.name)}
|
|
589
|
+
className="inline-flex items-center gap-1 text-red-600 hover:underline"
|
|
590
|
+
>
|
|
591
|
+
<Trash2 className="size-3.5" /> Delete forever
|
|
592
|
+
</button>
|
|
593
|
+
) : (
|
|
594
|
+
<span className="text-[0.7rem] uppercase tracking-widest text-ink/40">
|
|
595
|
+
Superadmin only
|
|
596
|
+
</span>
|
|
597
|
+
)}
|
|
598
|
+
</td>
|
|
599
|
+
</tr>
|
|
600
|
+
))}
|
|
601
|
+
{(trash?.rows.length ?? 0) === 0 && (
|
|
602
|
+
<tr>
|
|
603
|
+
<td colSpan={5} className="px-4 py-8 text-center text-ink/50">
|
|
604
|
+
Trash bin is empty.
|
|
605
|
+
</td>
|
|
606
|
+
</tr>
|
|
607
|
+
)}
|
|
608
|
+
</tbody>
|
|
609
|
+
</table>
|
|
610
|
+
</div>
|
|
528
611
|
</div>
|
|
529
612
|
|
|
530
613
|
<div className="mt-10 border border-ink/10 bg-white p-6">
|
|
@@ -573,6 +656,14 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
|
|
|
573
656
|
statusChange.page.publication_transition,
|
|
574
657
|
)}
|
|
575
658
|
details={[`${statusChange.page.name} — ${pathFor(statusChange.page.key)}`]}
|
|
659
|
+
consequences={
|
|
660
|
+
statusChange.next === "published"
|
|
661
|
+
? undefined
|
|
662
|
+
: unpublishConsequences({
|
|
663
|
+
path: pathFor(statusChange.page.key),
|
|
664
|
+
listing: normalizePageListing(statusChange.page.listing),
|
|
665
|
+
})
|
|
666
|
+
}
|
|
576
667
|
confirmLabel={statusChange.next === "published" ? "Publish" : "Unpublish"}
|
|
577
668
|
danger={statusChange.next !== "published"}
|
|
578
669
|
busy={busy}
|
|
@@ -581,6 +672,31 @@ export function PagesListView({ deps }: { deps: PagesListDeps }): ReactNode {
|
|
|
581
672
|
/>
|
|
582
673
|
) : null}
|
|
583
674
|
|
|
675
|
+
{listingChange ? (
|
|
676
|
+
(() => {
|
|
677
|
+
const facts = {
|
|
678
|
+
name: listingChange.page.name,
|
|
679
|
+
path: pathFor(listingChange.page.key),
|
|
680
|
+
live: isPubliclyVisible(listingChange.page.publication),
|
|
681
|
+
};
|
|
682
|
+
const copy =
|
|
683
|
+
listingChange.next === "link-only"
|
|
684
|
+
? linkOnlyConfirmation(facts)
|
|
685
|
+
: showOnWebsiteConfirmation(facts);
|
|
686
|
+
return (
|
|
687
|
+
<ConfirmDialog
|
|
688
|
+
title={copy.title}
|
|
689
|
+
description={copy.body[0]}
|
|
690
|
+
consequences={copy.body.slice(1)}
|
|
691
|
+
confirmLabel={copy.confirmLabel}
|
|
692
|
+
busy={busy}
|
|
693
|
+
onConfirm={onConfirmListing}
|
|
694
|
+
onCancel={() => setListingChange(null)}
|
|
695
|
+
/>
|
|
696
|
+
);
|
|
697
|
+
})()
|
|
698
|
+
) : null}
|
|
699
|
+
|
|
584
700
|
{duplicating ? (
|
|
585
701
|
<ConfirmDialog
|
|
586
702
|
title="Duplicate page"
|
|
@@ -47,6 +47,8 @@ export type SiteSettingsDeps = {
|
|
|
47
47
|
*/
|
|
48
48
|
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
|
49
49
|
MediaField?: (props: any) => ReactNode;
|
|
50
|
+
/** Site settings → Booking, rendered after Modules when the host supports it. */
|
|
51
|
+
bookingSection?: ReactNode;
|
|
50
52
|
};
|
|
51
53
|
|
|
52
54
|
// Labels and order come from the module table, so a new module appears here
|
|
@@ -290,6 +292,8 @@ export function SiteSettingsView({ deps }: { deps: SiteSettingsDeps }) {
|
|
|
290
292
|
</div>
|
|
291
293
|
</section>
|
|
292
294
|
|
|
295
|
+
{deps.bookingSection ?? null}
|
|
296
|
+
|
|
293
297
|
{Object.keys(brand).length > 0 && (
|
|
294
298
|
<section className={`mt-6 ${cardClass}`}>
|
|
295
299
|
<SectionHeader
|
|
@@ -15,26 +15,30 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { ReactNode } from "react";
|
|
18
|
-
import { CalendarClock, CircleDot, CircleSlash, Eye, EyeOff, FileEdit } from "lucide-react";
|
|
18
|
+
import { CalendarClock, CircleDot, CircleSlash, Eye, EyeOff, FileEdit, Link2, Lock } from "lucide-react";
|
|
19
19
|
import { PAGE_STATUS_DESCRIPTIONS, PAGE_STATUS_LABELS, type PageStatus } from "../cms/page-status";
|
|
20
20
|
import {
|
|
21
21
|
publicationSummary,
|
|
22
22
|
type PublicationState,
|
|
23
23
|
type PublicationTransitionKind,
|
|
24
24
|
} from "../cms/page-publication";
|
|
25
|
+
import { PAGE_VISIBILITY_DESCRIPTIONS, PROTECTED_PAGE_REASON, type PageListing } from "../cms/page-visibility";
|
|
26
|
+
import { isPubliclyVisible } from "../cms/page-publication";
|
|
25
27
|
import {
|
|
26
28
|
VISIBILITY_STATE_DESCRIPTIONS,
|
|
27
29
|
VISIBILITY_STATE_LABELS,
|
|
28
30
|
type VisibilityState,
|
|
29
31
|
} from "../cms/visibility-schedule";
|
|
30
32
|
|
|
31
|
-
export type StatusTone = "draft" | "live" | "hidden" | "scheduled";
|
|
33
|
+
export type StatusTone = "draft" | "live" | "hidden" | "scheduled" | "unlisted" | "protected";
|
|
32
34
|
|
|
33
35
|
const TONE_CLASS: Record<StatusTone, string> = {
|
|
34
36
|
draft: "border-ink/25 bg-ink/[0.04] text-ink/70",
|
|
35
37
|
live: "border-sea/40 bg-ice/60 text-ink",
|
|
36
38
|
hidden: "border-ink/20 bg-ink/[0.02] text-ink/50",
|
|
37
39
|
scheduled: "border-brass/50 bg-brass/10 text-ink/80",
|
|
40
|
+
unlisted: "border-ink/40 bg-paper text-ink",
|
|
41
|
+
protected: "border-ink/15 bg-ink/[0.02] text-ink/60",
|
|
38
42
|
};
|
|
39
43
|
|
|
40
44
|
const TONE_ICON: Record<StatusTone, (props: { className?: string }) => ReactNode> = {
|
|
@@ -42,6 +46,8 @@ const TONE_ICON: Record<StatusTone, (props: { className?: string }) => ReactNode
|
|
|
42
46
|
live: CircleDot,
|
|
43
47
|
hidden: CircleSlash,
|
|
44
48
|
scheduled: CalendarClock,
|
|
49
|
+
unlisted: Link2,
|
|
50
|
+
protected: Lock,
|
|
45
51
|
};
|
|
46
52
|
|
|
47
53
|
const PUBLICATION_TONE: Record<PublicationState, StatusTone> = {
|
|
@@ -172,3 +178,33 @@ export function VisibilityIcon({ visible }: { visible: boolean }) {
|
|
|
172
178
|
<EyeOff className="size-4" aria-hidden="true" />
|
|
173
179
|
);
|
|
174
180
|
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* `LINK ONLY` beside the publication badge when a page is unlisted.
|
|
184
|
+
*
|
|
185
|
+
* Nothing is shown for an ordinary listed page — that is the default and the
|
|
186
|
+
* publication badge already says everything. An offline page that is link-only
|
|
187
|
+
* says so in its own words, because publishing it will not list it.
|
|
188
|
+
*/
|
|
189
|
+
export function ListingBadge({
|
|
190
|
+
listing,
|
|
191
|
+
publication,
|
|
192
|
+
}: {
|
|
193
|
+
listing: PageListing;
|
|
194
|
+
publication: PublicationState;
|
|
195
|
+
}) {
|
|
196
|
+
if (listing !== "link-only") return null;
|
|
197
|
+
const live = isPubliclyVisible(publication);
|
|
198
|
+
return (
|
|
199
|
+
<StatusBadge
|
|
200
|
+
label={live ? "Link only" : "Link only once published"}
|
|
201
|
+
tone="unlisted"
|
|
202
|
+
title={PAGE_VISIBILITY_DESCRIPTIONS["link-only"]}
|
|
203
|
+
/>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Marks the start and contact pages, which cannot be unpublished or unlisted. */
|
|
208
|
+
export function ProtectedBadge() {
|
|
209
|
+
return <StatusBadge label="Protected" tone="protected" title={PROTECTED_PAGE_REASON} />;
|
|
210
|
+
}
|