@bygghammar/cms-platform 0.80.1 → 0.90.0-m7.10

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 (83) hide show
  1. package/CHANGELOG.md +489 -0
  2. package/README.md +16 -2
  3. package/docs/EDIT-LOCKS.md +213 -0
  4. package/docs/POPUPS.md +102 -0
  5. package/migrations/20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql +274 -0
  6. package/package.json +31 -3
  7. package/scripts/create-cms-site.mjs +5 -4
  8. package/scripts/sync-migrations.mjs +27 -6
  9. package/src/backoffice/ContactDetailsView.tsx +19 -5
  10. package/src/backoffice/DomainsView.tsx +152 -25
  11. package/src/backoffice/EditLock.tsx +513 -0
  12. package/src/backoffice/EmailSettingsView.tsx +99 -0
  13. package/src/backoffice/FormEditorView.tsx +531 -512
  14. package/src/backoffice/FormSettingsView.tsx +16 -4
  15. package/src/backoffice/GlobalSectionBuilderView.tsx +42 -11
  16. package/src/backoffice/NavigationView.tsx +35 -11
  17. package/src/backoffice/PageBuilderView.tsx +51 -16
  18. package/src/backoffice/RichTextEditor.tsx +50 -14
  19. package/src/backoffice/SectionEditorView.tsx +18 -6
  20. package/src/backoffice/SectionFormView.tsx +184 -31
  21. package/src/backoffice/SiteSettingsView.tsx +14 -15
  22. package/src/backoffice/TrackingView.tsx +247 -208
  23. package/src/backoffice/forms/VersionHistory.tsx +4 -2
  24. package/src/backoffice/inspectors/blocks.tsx +22 -1
  25. package/src/backoffice/inspectors/fields.tsx +21 -0
  26. package/src/backoffice/routes/host.ts +58 -7
  27. package/src/backoffice/routes/index.tsx +117 -15
  28. package/src/backoffice/routes/pages.tsx +10 -6
  29. package/src/backoffice/seo/PageSeoPanelView.tsx +215 -197
  30. package/src/backoffice/shell/domain-guard.ts +27 -14
  31. package/src/backoffice/shell/index.ts +5 -0
  32. package/src/backoffice/shell/nav-model.ts +32 -23
  33. package/src/backoffice/shell/use-resolved-config.tsx +67 -0
  34. package/src/backoffice/tracking-drafts.ts +150 -0
  35. package/src/blocks/components/CtaBannerBlock.tsx +18 -12
  36. package/src/blocks/components/HeroFullBlock.tsx +25 -12
  37. package/src/blocks/components/MediaSplitBlock.tsx +91 -41
  38. package/src/blocks/components/PriceTableBlock.tsx +123 -89
  39. package/src/blocks/components/RichTextBlock.tsx +26 -4
  40. package/src/blocks/index.ts +8 -0
  41. package/src/blocks/optional-content.ts +82 -0
  42. package/src/blocks/registry.tsx +1 -0
  43. package/src/blocks/types.ts +6 -0
  44. package/src/cms/edit-lock.ts +491 -0
  45. package/src/config/modules.ts +208 -0
  46. package/src/config/site-config.ts +9 -31
  47. package/src/forms/FormRenderer.tsx +62 -3
  48. package/src/forms/submission-result.ts +35 -0
  49. package/src/forms/success-listeners.ts +46 -0
  50. package/src/host/index.tsx +27 -1
  51. package/src/modules/index.ts +28 -0
  52. package/src/modules/public-runtime.tsx +159 -0
  53. package/src/navigation/index.ts +29 -0
  54. package/src/popups/admin.tsx +123 -0
  55. package/src/popups/cms-bridges.ts +240 -0
  56. package/src/popups/destinations.tsx +147 -0
  57. package/src/popups/host.ts +91 -0
  58. package/src/popups/media-bridge.tsx +84 -0
  59. package/src/popups/register.ts +23 -0
  60. package/src/popups/runtime.tsx +128 -0
  61. package/src/runtime/server-entry.ts +8 -2
  62. package/src/server/content-sections-core.ts +22 -5
  63. package/src/server/domains-core.ts +124 -107
  64. package/src/server/edit-lock-adapter.ts +91 -0
  65. package/src/server/edit-lock-supabase-adapter.ts +135 -0
  66. package/src/server/edit-locks-core.ts +286 -0
  67. package/src/server/forms-core.ts +37 -2
  68. package/src/server/injections-core.ts +230 -49
  69. package/src/server/page-seo-save-core.ts +27 -0
  70. package/src/server/pages-core.ts +108 -11
  71. package/src/server/popup-optin-trigger.ts +111 -0
  72. package/src/server/popup-optin-webhook-core.ts +363 -0
  73. package/src/server/provider-secrets.ts +100 -4
  74. package/src/server/schemas.ts +74 -9
  75. package/src/server/webhook-url-guard.ts +199 -0
  76. package/src/site/SiteHeader.tsx +11 -5
  77. package/src/site/host.ts +172 -0
  78. package/src/site/locale-domains.ts +500 -0
  79. package/src/site/routes.ts +206 -38
  80. package/src/site/site-url.ts +80 -17
  81. package/src/tracking/injections.ts +52 -9
  82. package/src/types/css-modules.d.ts +6 -0
  83. package/src/ui/Reveal.tsx +74 -9
@@ -6,12 +6,16 @@ import type { FormDefaults } from "../forms/field-sets";
6
6
  import { InspectorSection, LocalizedField, TextField } from "./inspectors/fields";
7
7
  import { SmartTagTextField, SmartTagLocalizedField } from "./forms/SmartTags";
8
8
  import { POLICY_WORDS, normalizePolicyUrl, renderLinkedText } from "../forms/rich-text";
9
+ import { EDIT_LOCK_TYPES } from "../cms/edit-lock";
10
+ import { EditLockBanner, EditLockFence, useEditLock } from "./EditLock";
9
11
 
10
12
  /** Host-provided data access for the shared form settings screen. */
11
13
  export type FormSettingsDeps = {
12
14
  defaults: FormDefaults | undefined;
13
15
  isLoading: boolean;
14
- saveDefaults: (input: { data: Record<string, unknown> }) => Promise<unknown>;
16
+ saveDefaults: (input: {
17
+ data: Record<string, unknown> & { editSessionId?: string };
18
+ }) => Promise<unknown>;
15
19
  purgeOldSubmissions: (input: {
16
20
  data: { dryRun: boolean };
17
21
  }) => Promise<{ matched: number; deleted: number; cutoff: string }>;
@@ -25,6 +29,11 @@ export type FormSettingsDeps = {
25
29
  export function FormSettingsView({ deps }: { deps: FormSettingsDeps }) {
26
30
  const [s, setS] = useState<FormDefaults | null>(null);
27
31
  const [busy, setBusy] = useState(false);
32
+ // One shared row for every form, so a single well-known key locks it.
33
+ const lock = useEditLock({
34
+ resourceType: EDIT_LOCK_TYPES.formDefaults,
35
+ resourceId: "form_settings",
36
+ });
28
37
  const [purgeInfo, setPurgeInfo] = useState<{
29
38
  matched: number;
30
39
  deleted: number;
@@ -48,6 +57,7 @@ export function FormSettingsView({ deps }: { deps: FormSettingsDeps }) {
48
57
  try {
49
58
  await deps.saveDefaults({
50
59
  data: {
60
+ editSessionId: lock.sessionId,
51
61
  notify_to: s.notify_to,
52
62
  notify_enabled: s.notify_enabled,
53
63
  notify_subject: s.notify_subject,
@@ -100,7 +110,7 @@ export function FormSettingsView({ deps }: { deps: FormSettingsDeps }) {
100
110
  </div>
101
111
  <button
102
112
  onClick={onSave}
103
- disabled={busy}
113
+ disabled={busy || lock.blocked}
104
114
  className="inline-flex items-center gap-2 bg-ink px-5 py-2.5 text-xs uppercase tracking-[0.2em] text-paper hover:bg-sea hover:text-ink disabled:opacity-50"
105
115
  >
106
116
  <Save className="size-4" />
@@ -108,7 +118,9 @@ export function FormSettingsView({ deps }: { deps: FormSettingsDeps }) {
108
118
  </button>
109
119
  </div>
110
120
 
111
- <div className="mt-8 grid gap-6 lg:grid-cols-2">
121
+ <EditLockBanner lock={lock} className="mt-6" />
122
+
123
+ <EditLockFence lock={lock} className="mt-8 grid gap-6 lg:grid-cols-2">
112
124
  <div className="border border-ink/10 bg-white p-6 space-y-4">
113
125
  <InspectorSection title="Incomplete submissions">
114
126
  <label className="flex items-center gap-3 text-sm text-ink/70">
@@ -430,7 +442,7 @@ export function FormSettingsView({ deps }: { deps: FormSettingsDeps }) {
430
442
  </p>
431
443
  </InspectorSection>
432
444
  </div>
433
- </div>
445
+ </EditLockFence>
434
446
  </div>
435
447
  );
436
448
  }
@@ -1,4 +1,7 @@
1
1
  import { useEffect, useState, type ComponentType, type ReactNode } from "react";
2
+
3
+ import { EDIT_LOCK_TYPES } from "../cms/edit-lock";
4
+ import { EditLockBanner, EditLockFence, useEditLock } from "./EditLock";
2
5
  import { toast } from "sonner";
3
6
  import { ArrowLeft, Save, Pencil, Layers, Globe } from "lucide-react";
4
7
 
@@ -26,9 +29,22 @@ export type GlobalSectionBuilderDeps<TBlock> = {
26
29
  allGlobals: { key: string; name: string; kind?: string; blocks?: unknown }[];
27
30
  /** Places this global is used, shown as a save-impact note. */
28
31
  usage: { name: string }[];
29
- save: (input: { sectionId: string; blocks: TBlock[] }) => Promise<unknown>;
30
- rename: (input: { sectionId: string; name: string }) => Promise<unknown>;
31
- setKind: (input: { sectionId: string; kind: GlobalSectionKind }) => Promise<unknown>;
32
+ save: (input: {
33
+ sectionId: string;
34
+ blocks: TBlock[];
35
+ /** Editing session holding the lock; the server checks it before writing. */
36
+ editSessionId?: string;
37
+ }) => Promise<unknown>;
38
+ rename: (input: {
39
+ sectionId: string;
40
+ name: string;
41
+ editSessionId?: string;
42
+ }) => Promise<unknown>;
43
+ setKind: (input: {
44
+ sectionId: string;
45
+ kind: GlobalSectionKind;
46
+ editSessionId?: string;
47
+ }) => Promise<unknown>;
32
48
  refetch: () => void;
33
49
  /** Navigates back to the blocks or global-sections library. */
34
50
  goToLibrary: (kind: GlobalSectionKind) => void;
@@ -89,6 +105,13 @@ export function GlobalSectionBuilderView<TBlock>({
89
105
  const [dirty, setDirty] = useState(false);
90
106
  const [busy, setBusy] = useState(false);
91
107
 
108
+ // A global block is used on every page that references it, so two people
109
+ // editing one at once is the case worth catching most of all.
110
+ const lock = useEditLock({
111
+ resourceType: EDIT_LOCK_TYPES.globalSection,
112
+ resourceId: section?.id,
113
+ });
114
+
92
115
  useEffect(() => {
93
116
  if (blocksFromServer) {
94
117
  setBlocks(blocksFromServer);
@@ -101,7 +124,7 @@ export function GlobalSectionBuilderView<TBlock>({
101
124
  if (!section) return;
102
125
  setBusy(true);
103
126
  try {
104
- await save({ sectionId: section.id, blocks });
127
+ await save({ sectionId: section.id, blocks, editSessionId: lock.sessionId });
105
128
  toast.success("Saved");
106
129
  setDirty(false);
107
130
  refetch();
@@ -117,7 +140,7 @@ export function GlobalSectionBuilderView<TBlock>({
117
140
  const next = window.prompt("Name", section.name);
118
141
  if (!next || next.trim() === "" || next === section.name) return;
119
142
  try {
120
- await rename({ sectionId: section.id, name: next.trim() });
143
+ await rename({ sectionId: section.id, name: next.trim(), editSessionId: lock.sessionId });
121
144
  toast.success("Renamed");
122
145
  refetch();
123
146
  } catch (e) {
@@ -132,7 +155,7 @@ export function GlobalSectionBuilderView<TBlock>({
132
155
  return;
133
156
  }
134
157
  try {
135
- await setKind({ sectionId: section.id, kind });
158
+ await setKind({ sectionId: section.id, kind, editSessionId: lock.sessionId });
136
159
  toast.success(kind === "block" ? "Now a global block" : "Now a global section");
137
160
  refetch();
138
161
  } catch (e) {
@@ -177,7 +200,12 @@ export function GlobalSectionBuilderView<TBlock>({
177
200
  </div>
178
201
  <div className="flex items-center gap-2 mt-1">
179
202
  <h1 className="font-serif text-3xl md:text-4xl text-ink">{section.name}</h1>
180
- <button onClick={onRename} title="Rename" className="text-ink/40 hover:text-ink">
203
+ <button
204
+ onClick={onRename}
205
+ disabled={lock.blocked}
206
+ title="Rename"
207
+ className="text-ink/40 hover:text-ink disabled:opacity-40"
208
+ >
181
209
  <Pencil className="size-4" />
182
210
  </button>
183
211
  </div>
@@ -194,13 +222,14 @@ export function GlobalSectionBuilderView<TBlock>({
194
222
  </div>
195
223
  <button
196
224
  onClick={() => onConvert(isBlock ? "section" : "block")}
197
- className="mt-2 text-xs text-sea hover:underline"
225
+ disabled={lock.blocked}
226
+ className="mt-2 text-xs text-sea hover:underline disabled:opacity-40 disabled:no-underline"
198
227
  >
199
228
  {isBlock ? "Convert to global section" : "Convert to global block"}
200
229
  </button>
201
230
  </div>
202
231
  <button
203
- disabled={busy || !dirty}
232
+ disabled={busy || !dirty || lock.blocked}
204
233
  onClick={onSave}
205
234
  className="inline-flex items-center gap-2 bg-ink text-white px-4 py-2 text-sm disabled:opacity-40"
206
235
  >
@@ -208,7 +237,9 @@ export function GlobalSectionBuilderView<TBlock>({
208
237
  </button>
209
238
  </div>
210
239
 
211
- <div className="mt-6">
240
+ <EditLockBanner lock={lock} className="mt-4" />
241
+
242
+ <EditLockFence lock={lock} className="mt-6 block">
212
243
  <Editor
213
244
  pageKey={`gs:${sectionKey}`}
214
245
  initialBlocks={initialBlocks}
@@ -219,7 +250,7 @@ export function GlobalSectionBuilderView<TBlock>({
219
250
  onDirtyChange={setDirty}
220
251
  singleBlock={isBlock}
221
252
  />
222
- </div>
253
+ </EditLockFence>
223
254
  </div>
224
255
  );
225
256
  }
@@ -3,6 +3,9 @@ import type { ComponentType } from "react";
3
3
  import { RotateCcw, Save } from "lucide-react";
4
4
  import { toast } from "sonner";
5
5
 
6
+ import { EDIT_LOCK_TYPES } from "../cms/edit-lock";
7
+ import { EditLockBanner, EditLockFence, useEditLock } from "./EditLock";
8
+
6
9
  /** The two site-wide content sections this screen edits. */
7
10
  export type NavigationSectionKey = "nav" | "footer";
8
11
 
@@ -16,7 +19,14 @@ export type NavigationDeps = {
16
19
  defaults: Record<string, unknown>;
17
20
  /** The host's section editor, already wired to its locales and media. */
18
21
  SectionForm: ComponentType<{ value: unknown; onChange: (next: unknown) => void }>;
19
- saveSection: (input: { data: { key: string; data: unknown } }) => Promise<unknown>;
22
+ saveSection: (input: {
23
+ data: {
24
+ key: string;
25
+ data: unknown;
26
+ /** Editing session holding the lock; the server checks it before writing. */
27
+ editSessionId?: string;
28
+ };
29
+ }) => Promise<unknown>;
20
30
  refresh: () => Promise<unknown> | void;
21
31
  };
22
32
 
@@ -42,6 +52,13 @@ function Editor({
42
52
  const [busy, setBusy] = useState(false);
43
53
  const [dirty, setDirty] = useState(false);
44
54
 
55
+ // Navigation and the footer are separate rows, so they lock separately: one
56
+ // person can rework the menu while another fixes a footer link.
57
+ const lock = useEditLock({
58
+ resourceType: EDIT_LOCK_TYPES.contentSection,
59
+ resourceId: sectionKey,
60
+ });
61
+
45
62
  useEffect(() => {
46
63
  setDraft(current);
47
64
  setDirty(false);
@@ -50,7 +67,9 @@ function Editor({
50
67
  const onSave = async () => {
51
68
  setBusy(true);
52
69
  try {
53
- await saveSection({ data: { key: sectionKey, data: draft } });
70
+ await saveSection({
71
+ data: { key: sectionKey, data: draft, editSessionId: lock.sessionId },
72
+ });
54
73
  await refresh();
55
74
  toast.success(`${title} saved`);
56
75
  setDirty(false);
@@ -79,18 +98,22 @@ function Editor({
79
98
  </header>
80
99
 
81
100
  <div className="p-6">
82
- <SectionForm
83
- value={draft}
84
- onChange={(next) => {
85
- setDraft(next);
86
- setDirty(true);
87
- }}
88
- />
101
+ <EditLockBanner lock={lock} className="mb-6" />
102
+
103
+ <EditLockFence lock={lock}>
104
+ <SectionForm
105
+ value={draft}
106
+ onChange={(next) => {
107
+ setDraft(next);
108
+ setDirty(true);
109
+ }}
110
+ />
111
+ </EditLockFence>
89
112
 
90
113
  <div className="mt-6 flex flex-wrap items-center gap-3">
91
114
  <button
92
115
  onClick={onSave}
93
- disabled={busy || !dirty}
116
+ disabled={busy || !dirty || lock.blocked}
94
117
  className="inline-flex items-center gap-2 bg-ink text-paper px-6 py-3 text-xs uppercase tracking-[0.22em] hover:bg-ink/85 disabled:opacity-50"
95
118
  >
96
119
  <Save className="size-4" />
@@ -98,7 +121,8 @@ function Editor({
98
121
  </button>
99
122
  <button
100
123
  onClick={onReset}
101
- className="inline-flex items-center gap-2 border border-ink/20 px-6 py-3 text-xs uppercase tracking-[0.22em] hover:border-ink"
124
+ disabled={lock.blocked}
125
+ className="inline-flex items-center gap-2 border border-ink/20 px-6 py-3 text-xs uppercase tracking-[0.22em] hover:border-ink disabled:opacity-50"
102
126
  >
103
127
  <RotateCcw className="size-4" />
104
128
  Reset to default
@@ -8,6 +8,9 @@
8
8
  */
9
9
 
10
10
  import { useEffect, useState, type ReactNode } from "react";
11
+
12
+ import { EDIT_LOCK_TYPES } from "../cms/edit-lock";
13
+ import { EditLockBanner, EditLockFence, useEditLock } from "./EditLock";
11
14
  import {
12
15
  ArrowLeft,
13
16
  Save,
@@ -76,6 +79,8 @@ export type PageBuilderDeps<TBlock> = {
76
79
  blocks: TBlock[];
77
80
  publish: boolean;
78
81
  note?: string;
82
+ /** Editing session holding the lock; the server checks it before writing. */
83
+ editSessionId?: string;
79
84
  }) => Promise<{ versionId: string }>;
80
85
  /** Persists unsaved shared-content edits; returns the keys it saved. */
81
86
  flushSharedDrafts?: () => Promise<string[]>;
@@ -84,10 +89,12 @@ export type PageBuilderDeps<TBlock> = {
84
89
  publishAt: string;
85
90
  unpublishAt: string | null;
86
91
  note?: string;
92
+ /** Editing session holding the lock; the server checks it before writing. */
93
+ editSessionId?: string;
87
94
  }) => Promise<unknown>;
88
- cancelPublication: (publicationId: string) => Promise<unknown>;
89
- restoreVersion: (versionId: string) => Promise<unknown>;
90
- rename: (input: { pageId: string; name: string }) => Promise<unknown>;
95
+ cancelPublication: (publicationId: string, editSessionId?: string) => Promise<unknown>;
96
+ restoreVersion: (versionId: string, editSessionId?: string) => Promise<unknown>;
97
+ rename: (input: { pageId: string; name: string; editSessionId?: string }) => Promise<unknown>;
91
98
  refetchPage: () => void;
92
99
  refetchVersions: () => void;
93
100
  refetchPublications: () => void;
@@ -136,6 +143,10 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
136
143
  const [locale, setLocale] = useState<string>(defaultLocale);
137
144
  const [copied, setCopied] = useState(false);
138
145
 
146
+ // Held for as long as this builder is open. Without a lock provider the
147
+ // handle reports "not enforced" and nothing below changes.
148
+ const lock = useEditLock({ resourceType: EDIT_LOCK_TYPES.page, resourceId: page?.id });
149
+
139
150
  useEffect(() => {
140
151
  if (blocksFromServer) {
141
152
  setBlocks(blocksFromServer);
@@ -172,7 +183,11 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
172
183
  const onRename = async () => {
173
184
  if (!page || !renameValue?.trim()) return;
174
185
  try {
175
- await deps.rename({ pageId: page.id, name: renameValue.trim() });
186
+ await deps.rename({
187
+ pageId: page.id,
188
+ name: renameValue.trim(),
189
+ editSessionId: lock.sessionId,
190
+ });
176
191
  notify.success("Page renamed");
177
192
  setRenameValue(null);
178
193
  deps.refetchPage();
@@ -188,7 +203,7 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
188
203
  // Shared content-section edits made in the inspector are part of the page
189
204
  // from the editor's point of view, so Save/Publish must cover them too.
190
205
  await deps.flushSharedDrafts?.();
191
- await deps.save({ pageId: page.id, blocks, publish });
206
+ await deps.save({ pageId: page.id, blocks, publish, editSessionId: lock.sessionId });
192
207
  notify.success(publish ? "Published" : "Draft saved");
193
208
  setDirty(false);
194
209
  deps.refetchPage();
@@ -212,12 +227,14 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
212
227
  blocks,
213
228
  publish: false,
214
229
  note: scheduleNote || "Scheduled draft",
230
+ editSessionId: lock.sessionId,
215
231
  });
216
232
  await deps.schedule({
217
233
  versionId: res.versionId,
218
234
  publishAt: new Date(publishAt).toISOString(),
219
235
  unpublishAt: unpublishAt ? new Date(unpublishAt).toISOString() : null,
220
236
  note: scheduleNote || undefined,
237
+ editSessionId: lock.sessionId,
221
238
  });
222
239
  notify.success("Scheduled");
223
240
  setDirty(false);
@@ -236,7 +253,7 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
236
253
  const onCancelPub = async (id: string) => {
237
254
  if (!confirm("Cancel this scheduled publication?")) return;
238
255
  try {
239
- await deps.cancelPublication(id);
256
+ await deps.cancelPublication(id, lock.sessionId);
240
257
  notify.success("Cancelled");
241
258
  deps.refetchPublications();
242
259
  } catch (e) {
@@ -247,7 +264,7 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
247
264
  const onRestore = async (versionId: string) => {
248
265
  if (!confirm("Restore this version? This will publish it as the current live version.")) return;
249
266
  try {
250
- await deps.restoreVersion(versionId);
267
+ await deps.restoreVersion(versionId, lock.sessionId);
251
268
  notify.success("Restored");
252
269
  setShowHistory(false);
253
270
  deps.refetchPage();
@@ -277,7 +294,8 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
277
294
  {page.name}
278
295
  <button
279
296
  onClick={() => setRenameValue(page.name)}
280
- className="text-xs font-sans text-ink/50 hover:text-sea underline"
297
+ disabled={lock.blocked}
298
+ className="text-xs font-sans text-ink/50 hover:text-sea underline disabled:opacity-40"
281
299
  >
282
300
  rename
283
301
  </button>
@@ -286,6 +304,7 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
286
304
  <div className="mt-1 flex items-center gap-2">
287
305
  <input
288
306
  autoFocus
307
+ disabled={lock.blocked}
289
308
  value={renameValue}
290
309
  onChange={(e) => setRenameValue(e.target.value)}
291
310
  onKeyDown={(e) => {
@@ -294,7 +313,11 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
294
313
  }}
295
314
  className="border border-ink/20 px-3 py-2 text-lg"
296
315
  />
297
- <button onClick={onRename} className="text-sm text-sea hover:underline">
316
+ <button
317
+ onClick={onRename}
318
+ disabled={lock.blocked}
319
+ className="text-sm text-sea hover:underline disabled:opacity-40 disabled:no-underline"
320
+ >
298
321
  Save
299
322
  </button>
300
323
  <button
@@ -367,14 +390,14 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
367
390
  <History className="size-4" /> History
368
391
  </button>
369
392
  <button
370
- disabled={busy || !dirty}
393
+ disabled={busy || !dirty || lock.blocked}
371
394
  onClick={() => onSave(false)}
372
395
  className="inline-flex items-center gap-2 border border-ink/30 px-4 py-2 text-sm text-ink hover:border-sea disabled:opacity-40"
373
396
  >
374
397
  <Save className="size-4" /> Save draft
375
398
  </button>
376
399
  <button
377
- disabled={busy}
400
+ disabled={busy || lock.blocked}
378
401
  onClick={() => onSave(true)}
379
402
  className="inline-flex items-center gap-2 bg-ink text-white px-4 py-2 text-sm disabled:opacity-40"
380
403
  >
@@ -383,6 +406,8 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
383
406
  </div>
384
407
  </div>
385
408
 
409
+ <EditLockBanner lock={lock} className="mt-4" />
410
+
386
411
  {showSchedule && (
387
412
  <div className="mt-6 border border-ink/10 bg-white p-4">
388
413
  <div className="flex items-center justify-between mb-3">
@@ -400,6 +425,7 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
400
425
  </span>
401
426
  <input
402
427
  type="datetime-local"
428
+ disabled={lock.blocked}
403
429
  value={publishAt}
404
430
  onChange={(e) => setPublishAt(e.target.value)}
405
431
  className="w-full border border-ink/20 px-2 py-1.5 text-sm"
@@ -411,6 +437,7 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
411
437
  </span>
412
438
  <input
413
439
  type="datetime-local"
440
+ disabled={lock.blocked}
414
441
  value={unpublishAt}
415
442
  onChange={(e) => setUnpublishAt(e.target.value)}
416
443
  className="w-full border border-ink/20 px-2 py-1.5 text-sm"
@@ -419,6 +446,7 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
419
446
  <label className="block md:col-span-1">
420
447
  <span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">Note</span>
421
448
  <input
449
+ disabled={lock.blocked}
422
450
  value={scheduleNote}
423
451
  onChange={(e) => setScheduleNote(e.target.value)}
424
452
  className="w-full border border-ink/20 px-2 py-1.5 text-sm"
@@ -427,7 +455,7 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
427
455
  </label>
428
456
  <div className="flex items-end">
429
457
  <button
430
- disabled={busy || !publishAt}
458
+ disabled={busy || !publishAt || lock.blocked}
431
459
  onClick={onSchedule}
432
460
  className="w-full inline-flex items-center justify-center gap-2 bg-sea text-ink px-4 py-2 text-sm disabled:opacity-40 border border-ink/10"
433
461
  >
@@ -488,7 +516,8 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
488
516
  </div>
489
517
  {p.is_active && status !== "expired" && (
490
518
  <button
491
- className="text-red-600 hover:underline"
519
+ className="text-red-600 hover:underline disabled:opacity-40 disabled:no-underline"
520
+ disabled={lock.blocked}
492
521
  onClick={() => onCancelPub(p.id)}
493
522
  >
494
523
  Cancel
@@ -537,7 +566,11 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
537
566
  {open ? "Hide changes" : "View changes"}
538
567
  </button>
539
568
  )}
540
- <button className="text-sea hover:underline" onClick={() => onRestore(v.id)}>
569
+ <button
570
+ className="text-sea hover:underline disabled:opacity-40 disabled:no-underline"
571
+ disabled={lock.blocked}
572
+ onClick={() => onRestore(v.id)}
573
+ >
541
574
  Restore
542
575
  </button>
543
576
  </div>
@@ -563,9 +596,11 @@ export function PageBuilderView<TBlock>({ deps }: { deps: PageBuilderDeps<TBlock
563
596
  </div>
564
597
  )}
565
598
 
566
- <div className="mt-6">
599
+ {/* The canvas itself: fenced so a blocked editor cannot rearrange blocks
600
+ or retype copy that the server will refuse to save. */}
601
+ <EditLockFence lock={lock} className="mt-6 block">
567
602
  {deps.renderEditor({ blocks, setBlocks, locale, setLocale, setDirty })}
568
- </div>
603
+ </EditLockFence>
569
604
 
570
605
  {deps.seoPanel && (
571
606
  <div className="mt-12 border-t border-ink/10 pt-8">
@@ -34,6 +34,8 @@ import {
34
34
  Underline as UnderlineIcon,
35
35
  Undo2,
36
36
  Unlink,
37
+ Maximize2,
38
+ Minimize2,
37
39
  MoveHorizontal,
38
40
  } from "lucide-react";
39
41
  import { MediaPickerDialog } from "./inspectors/fields";
@@ -635,21 +637,37 @@ function Toolbar({ editor }: { editor: Editor }) {
635
637
  );
636
638
  }
637
639
 
640
+ /** Editing height: comfortable by default, near-full-screen when expanded. */
641
+ const HEIGHTS = {
642
+ normal: "max-h-[32rem]",
643
+ expanded: "max-h-[calc(100vh-14rem)]",
644
+ } as const;
645
+
638
646
  /**
639
647
  * Advanced WYSIWYG editor (TipTap) used by the rich-text block.
640
648
  * Emits sanitized HTML; also offers a raw HTML source view for pasting
641
649
  * markup from elsewhere.
650
+ *
651
+ * There is no length limit anywhere in the path this editor feeds — not here,
652
+ * not in the request schema, not in the `jsonb` column — so a long document is
653
+ * a matter of ergonomics rather than capacity. The editing area scrolls
654
+ * internally and expands to nearly the full window, which is what makes a long
655
+ * one workable.
642
656
  */
643
657
  export function RichTextEditor({
644
658
  value,
645
659
  onChange,
646
660
  placeholder = "Write or paste your text…",
661
+ readOnly = false,
647
662
  }: {
648
663
  value: string;
649
664
  onChange: (html: string) => void;
650
665
  placeholder?: string;
666
+ /** Show the text but refuse edits — used while somebody else holds the lock. */
667
+ readOnly?: boolean;
651
668
  }) {
652
669
  const [source, setSource] = useState(false);
670
+ const [expanded, setExpanded] = useState(false);
653
671
  const [draft, setDraft] = useState(value);
654
672
  const last = useRef(value);
655
673
 
@@ -682,6 +700,7 @@ export function RichTextEditor({
682
700
  Placeholder.configure({ placeholder }),
683
701
  ],
684
702
  content: value || "",
703
+ editable: !readOnly,
685
704
  editorProps: {
686
705
  attributes: {
687
706
  class: "rte-content focus:outline-none",
@@ -694,6 +713,12 @@ export function RichTextEditor({
694
713
  },
695
714
  });
696
715
 
716
+ // A `contenteditable` is not a form control, so a disabled fieldset around
717
+ // this editor does not stop it. Telling ProseMirror directly does.
718
+ useEffect(() => {
719
+ editor?.setEditable(!readOnly);
720
+ }, [editor, readOnly]);
721
+
697
722
  // Adopt external changes (locale switch, version rollback) without
698
723
  // clobbering what the user is typing.
699
724
  useEffect(() => {
@@ -721,8 +746,8 @@ export function RichTextEditor({
721
746
  value={draft}
722
747
  onChange={(e) => setDraft(e.target.value)}
723
748
  spellCheck={false}
724
- rows={16}
725
- className="w-full border border-ink/15 p-2 font-mono text-xs text-ink focus:border-sea focus:outline-none"
749
+ rows={expanded ? 40 : 16}
750
+ className="w-full resize-y border border-ink/15 p-2 font-mono text-xs text-ink focus:border-sea focus:outline-none"
726
751
  />
727
752
  <div className="mt-2 flex gap-2">
728
753
  <button
@@ -745,7 +770,7 @@ export function RichTextEditor({
745
770
  </div>
746
771
  </div>
747
772
  ) : (
748
- <div className="max-h-[32rem] overflow-auto p-4">
773
+ <div className={`${expanded ? HEIGHTS.expanded : HEIGHTS.normal} overflow-auto p-4`}>
749
774
  <EditorContent editor={editor} />
750
775
  </div>
751
776
  )}
@@ -755,17 +780,28 @@ export function RichTextEditor({
755
780
  ? `${editor.storage.characterCount?.characters?.() ?? editor.getText().length} chars`
756
781
  : ""}
757
782
  </span>
758
- <button
759
- type="button"
760
- onClick={() => {
761
- setDraft(value);
762
- setSource((s) => !s);
763
- }}
764
- className="flex items-center gap-1 text-[0.65rem] uppercase tracking-widest text-ink/60 hover:text-ink"
765
- >
766
- <Code2 className="size-3" />
767
- {source ? "Visual editor" : "HTML source"}
768
- </button>
783
+ <div className="flex items-center gap-4">
784
+ <button
785
+ type="button"
786
+ onClick={() => setExpanded((e) => !e)}
787
+ title={expanded ? "Shrink the editor" : "Give the editor more room"}
788
+ className="flex items-center gap-1 text-[0.65rem] uppercase tracking-widest text-ink/60 hover:text-ink"
789
+ >
790
+ {expanded ? <Minimize2 className="size-3" /> : <Maximize2 className="size-3" />}
791
+ {expanded ? "Shrink" : "Expand"}
792
+ </button>
793
+ <button
794
+ type="button"
795
+ onClick={() => {
796
+ setDraft(value);
797
+ setSource((s) => !s);
798
+ }}
799
+ className="flex items-center gap-1 text-[0.65rem] uppercase tracking-widest text-ink/60 hover:text-ink"
800
+ >
801
+ <Code2 className="size-3" />
802
+ {source ? "Visual editor" : "HTML source"}
803
+ </button>
804
+ </div>
769
805
  </div>
770
806
  </div>
771
807
  );