@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.
- package/CHANGELOG.md +489 -0
- package/README.md +16 -2
- package/docs/EDIT-LOCKS.md +213 -0
- package/docs/POPUPS.md +102 -0
- package/migrations/20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql +274 -0
- package/package.json +31 -3
- package/scripts/create-cms-site.mjs +5 -4
- package/scripts/sync-migrations.mjs +27 -6
- package/src/backoffice/ContactDetailsView.tsx +19 -5
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/EditLock.tsx +513 -0
- package/src/backoffice/EmailSettingsView.tsx +99 -0
- package/src/backoffice/FormEditorView.tsx +531 -512
- package/src/backoffice/FormSettingsView.tsx +16 -4
- package/src/backoffice/GlobalSectionBuilderView.tsx +42 -11
- package/src/backoffice/NavigationView.tsx +35 -11
- package/src/backoffice/PageBuilderView.tsx +51 -16
- package/src/backoffice/RichTextEditor.tsx +50 -14
- package/src/backoffice/SectionEditorView.tsx +18 -6
- package/src/backoffice/SectionFormView.tsx +184 -31
- package/src/backoffice/SiteSettingsView.tsx +14 -15
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/forms/VersionHistory.tsx +4 -2
- package/src/backoffice/inspectors/blocks.tsx +22 -1
- package/src/backoffice/inspectors/fields.tsx +21 -0
- package/src/backoffice/routes/host.ts +58 -7
- package/src/backoffice/routes/index.tsx +117 -15
- package/src/backoffice/routes/pages.tsx +10 -6
- package/src/backoffice/seo/PageSeoPanelView.tsx +215 -197
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/nav-model.ts +32 -23
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/components/CtaBannerBlock.tsx +18 -12
- package/src/blocks/components/HeroFullBlock.tsx +25 -12
- package/src/blocks/components/MediaSplitBlock.tsx +91 -41
- package/src/blocks/components/PriceTableBlock.tsx +123 -89
- package/src/blocks/components/RichTextBlock.tsx +26 -4
- package/src/blocks/index.ts +8 -0
- package/src/blocks/optional-content.ts +82 -0
- package/src/blocks/registry.tsx +1 -0
- package/src/blocks/types.ts +6 -0
- package/src/cms/edit-lock.ts +491 -0
- package/src/config/modules.ts +208 -0
- package/src/config/site-config.ts +9 -31
- package/src/forms/FormRenderer.tsx +62 -3
- package/src/forms/submission-result.ts +35 -0
- package/src/forms/success-listeners.ts +46 -0
- package/src/host/index.tsx +27 -1
- package/src/modules/index.ts +28 -0
- package/src/modules/public-runtime.tsx +159 -0
- package/src/navigation/index.ts +29 -0
- package/src/popups/admin.tsx +123 -0
- package/src/popups/cms-bridges.ts +240 -0
- package/src/popups/destinations.tsx +147 -0
- package/src/popups/host.ts +91 -0
- package/src/popups/media-bridge.tsx +84 -0
- package/src/popups/register.ts +23 -0
- package/src/popups/runtime.tsx +128 -0
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +22 -5
- package/src/server/domains-core.ts +124 -107
- package/src/server/edit-lock-adapter.ts +91 -0
- package/src/server/edit-lock-supabase-adapter.ts +135 -0
- package/src/server/edit-locks-core.ts +286 -0
- package/src/server/forms-core.ts +37 -2
- package/src/server/injections-core.ts +230 -49
- package/src/server/page-seo-save-core.ts +27 -0
- package/src/server/pages-core.ts +108 -11
- package/src/server/popup-optin-trigger.ts +111 -0
- package/src/server/popup-optin-webhook-core.ts +363 -0
- package/src/server/provider-secrets.ts +100 -4
- package/src/server/schemas.ts +74 -9
- package/src/server/webhook-url-guard.ts +199 -0
- package/src/site/SiteHeader.tsx +11 -5
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/tracking/injections.ts +52 -9
- package/src/types/css-modules.d.ts +6 -0
- 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: {
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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: {
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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: {
|
|
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({
|
|
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
|
-
<
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
</
|
|
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=
|
|
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
|
-
<
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
);
|