@bygghammar/cms-platform 0.94.0 → 0.96.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/docs/PAGES.md +72 -4
  3. package/package.json +8 -1
  4. package/src/admin/describe-log.ts +3 -0
  5. package/src/backoffice/BookingSettingsCard.tsx +252 -0
  6. package/src/backoffice/NavigationView.tsx +38 -0
  7. package/src/backoffice/PageBuilderView.tsx +149 -3
  8. package/src/backoffice/PageEditorView.tsx +47 -24
  9. package/src/backoffice/PagesListView.tsx +173 -57
  10. package/src/backoffice/SiteSettingsView.tsx +4 -0
  11. package/src/backoffice/StatusBadge.tsx +38 -2
  12. package/src/backoffice/media/ConfirmDialog.tsx +19 -1
  13. package/src/backoffice/routes/host.ts +24 -0
  14. package/src/backoffice/routes/index.tsx +54 -0
  15. package/src/backoffice/routes/pages.tsx +13 -0
  16. package/src/cms/booking-links.ts +176 -0
  17. package/src/cms/page-publication.ts +11 -6
  18. package/src/cms/page-visibility.ts +206 -0
  19. package/src/forms/form-audit.ts +22 -2
  20. package/src/html/html-tokenizer.ts +254 -0
  21. package/src/html/sanitize-html.ts +72 -37
  22. package/src/navigation/visibility.ts +235 -0
  23. package/src/popups/runtime.tsx +155 -4
  24. package/src/server/forms-core.ts +9 -0
  25. package/src/server/forms-delivery-core.ts +29 -22
  26. package/src/server/forms-submissions-core.ts +8 -5
  27. package/src/server/media-usage-core.ts +53 -13
  28. package/src/server/outbound-address.ts +131 -0
  29. package/src/server/outbound-http.ts +565 -0
  30. package/src/server/page-visibility-core.ts +450 -0
  31. package/src/server/pages-core.ts +238 -9
  32. package/src/server/popup-optin-webhook-core.ts +79 -140
  33. package/src/server/published-cache.ts +23 -3
  34. package/src/server/schemas.ts +17 -0
  35. package/src/site/PageRenderer.tsx +34 -5
  36. package/src/site/SiteFooter.tsx +11 -2
  37. package/src/site/SiteHeader.tsx +41 -21
  38. package/src/site/link-visibility.tsx +28 -0
  39. package/src/site/preview-messaging.ts +232 -0
  40. package/src/site/route-aliases.ts +74 -0
  41. package/src/site/routes/preview-page.tsx +75 -31
  42. package/src/site/routes/public-page.tsx +81 -9
  43. package/src/util/redact-url.ts +18 -0
@@ -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
- className="w-full border border-ink/20 px-2 py-1.5 text-sm"
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
  }
@@ -56,6 +56,12 @@ import {
56
56
  type InsertRefusal,
57
57
  } from "../blocks/block-tree";
58
58
  import { subscribeSharedDrafts } from "../cms/shared-draft-store";
59
+ import {
60
+ acceptPreviewMessage,
61
+ EDITOR_SOURCE,
62
+ newPreviewSession,
63
+ previewOriginOf,
64
+ } from "../site/preview-messaging";
59
65
  import { SaveGlobalDialog, type SaveGlobalResult } from "./SaveGlobalDialog";
60
66
 
61
67
  function uid(prefix = "b") {
@@ -570,6 +576,27 @@ export function PageEditorView({
570
576
  );
571
577
  const iframeRef = useRef<HTMLIFrameElement>(null);
572
578
  const [previewReady, setPreviewReady] = useState(false);
579
+ // The canvas is spoken to only at its own origin, in one session, in order
580
+ // (see preview-messaging); its answers count only from its own window.
581
+ const previewSession = useMemo(() => newPreviewSession(), []);
582
+ const previewSeq = useRef(0);
583
+ const previewOrigin = useMemo(
584
+ () =>
585
+ typeof window === "undefined" ? null : previewOriginOf(previewUrl(pageKey), window.location.href),
586
+ [previewUrl, pageKey],
587
+ );
588
+ const postToPreview = useCallback(
589
+ (message: Record<string, unknown>) => {
590
+ const frame = iframeRef.current?.contentWindow;
591
+ if (!frame || !previewOrigin) return;
592
+ previewSeq.current += 1;
593
+ frame.postMessage(
594
+ { source: EDITOR_SOURCE, session: previewSession, seq: previewSeq.current, ...message },
595
+ previewOrigin,
596
+ );
597
+ },
598
+ [previewOrigin, previewSession],
599
+ );
573
600
 
574
601
  const currentBlocks = controlledBlocks ?? blocks;
575
602
 
@@ -581,19 +608,15 @@ export function PageEditorView({
581
608
  // Push draft to iframe whenever blocks/locale/selection change.
582
609
  useEffect(() => {
583
610
  if (!previewReady) return;
584
- iframeRef.current?.contentWindow?.postMessage(
585
- {
586
- source: "lovable-editor",
587
- type: "draft",
588
- blocks: currentBlocks,
589
- globals,
590
- selectedId,
591
- locale,
592
- sharedContent: sharedDrafts,
593
- },
594
- "*",
595
- );
596
- }, [currentBlocks, globals, selectedId, locale, previewReady, sharedDrafts]);
611
+ postToPreview({
612
+ type: "draft",
613
+ blocks: currentBlocks,
614
+ globals,
615
+ selectedId,
616
+ locale,
617
+ sharedContent: sharedDrafts,
618
+ });
619
+ }, [currentBlocks, globals, selectedId, locale, previewReady, sharedDrafts, postToPreview]);
597
620
 
598
621
  useEffect(() => {
599
622
  const t = setTimeout(() => {
@@ -604,27 +627,27 @@ export function PageEditorView({
604
627
  }, [pageKey, currentBlocks, globals, locale]);
605
628
 
606
629
  /** Select a block from the outline and scroll the preview to it. */
607
- const selectAndReveal = useCallback((id: string) => {
608
- setSelectedId(id);
609
- iframeRef.current?.contentWindow?.postMessage(
610
- { source: "lovable-editor", type: "scrollTo", blockId: id },
611
- "*",
612
- );
613
- }, []);
630
+ const selectAndReveal = useCallback(
631
+ (id: string) => {
632
+ setSelectedId(id);
633
+ postToPreview({ type: "scrollTo", blockId: id });
634
+ },
635
+ [postToPreview],
636
+ );
614
637
 
615
638
  // Listen for iframe messages.
616
639
  useEffect(() => {
617
640
  const onMsg = (ev: MessageEvent) => {
618
- const d = ev.data as { source?: string; type?: string; blockId?: string };
619
- if (d?.source !== "lovable-preview") return;
641
+ const d = acceptPreviewMessage(ev, { frame: iframeRef.current?.contentWindow, previewOrigin });
642
+ if (!d) return;
620
643
  if (d.type === "ready") setPreviewReady(true);
621
- if (d.type === "select" && d.blockId) {
644
+ if (d.type === "select") {
622
645
  if (findBlockDeep(currentBlocks, d.blockId)) setSelectedId(d.blockId);
623
646
  }
624
647
  };
625
648
  window.addEventListener("message", onMsg);
626
649
  return () => window.removeEventListener("message", onMsg);
627
- }, [currentBlocks]);
650
+ }, [currentBlocks, previewOrigin]);
628
651
 
629
652
  // Keep the outline highlight visible whenever the selection changes.
630
653
  useEffect(() => {
@@ -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
- <div className="mt-8 border border-ink/10 bg-white">
327
- <table className="w-full text-sm">
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: "draft" })}
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
- <EyeOff className="size-3.5" /> Unpublish
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={() => setStatusChange({ page: p, next: "published" })}
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
- <Eye className="size-3.5" /> Publish
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
- <table className="w-full text-sm">
477
- <thead className="bg-ink/[0.03] text-left text-[0.7rem] uppercase tracking-[0.18em] text-ink/60">
478
- <tr>
479
- <th className="px-4 py-3">Name</th>
480
- <th className="px-4 py-3">Key</th>
481
- <th className="px-4 py-3">Deleted by</th>
482
- <th className="px-4 py-3">Deleted at</th>
483
- <th className="px-4 py-3 w-56"></th>
484
- </tr>
485
- </thead>
486
- <tbody>
487
- {trash?.rows.map((p) => (
488
- <tr key={p.id} className="border-t border-ink/10">
489
- <td className="px-4 py-3 text-ink">{p.name}</td>
490
- <td className="px-4 py-3 font-mono text-ink/70">{p.key}</td>
491
- <td className="px-4 py-3 text-ink/70">
492
- {p.deleted_by_email ?? p.deleted_by ?? "Unknown user"}
493
- </td>
494
- <td className="px-4 py-3 text-ink/50">
495
- {p.deleted_at ? new Date(p.deleted_at).toLocaleString() : "—"}
496
- </td>
497
- <td className="px-4 py-3 text-right">
498
- <button
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={() => onPurge(p.id, p.name)}
507
- className="inline-flex items-center gap-1 text-red-600 hover:underline"
581
+ onClick={() => onRestore(p.id)}
582
+ className="inline-flex items-center gap-1 text-sea hover:underline mr-3"
508
583
  >
509
- <Trash2 className="size-3.5" /> Delete forever
584
+ <Undo2 className="size-3.5" /> Restore
510
585
  </button>
511
- ) : (
512
- <span className="text-[0.7rem] uppercase tracking-widest text-ink/40">
513
- Superadmin only
514
- </span>
515
- )}
516
- </td>
517
- </tr>
518
- ))}
519
- {(trash?.rows.length ?? 0) === 0 && (
520
- <tr>
521
- <td colSpan={5} className="px-4 py-8 text-center text-ink/50">
522
- Trash bin is empty.
523
- </td>
524
- </tr>
525
- )}
526
- </tbody>
527
- </table>
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"