@bygghammar/cms-platform 0.90.0-m7.8 → 0.90.0-m7.9
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 +69 -0
- package/README.md +16 -2
- package/docs/EDIT-LOCKS.md +213 -0
- package/migrations/20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql +274 -0
- package/package.json +7 -1
- 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/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/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 +3 -0
- package/src/backoffice/routes/index.tsx +1 -0
- package/src/backoffice/routes/pages.tsx +10 -6
- package/src/backoffice/seo/PageSeoPanelView.tsx +215 -197
- 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/host/index.tsx +6 -0
- package/src/navigation/index.ts +29 -0
- package/src/server/content-sections-core.ts +22 -5
- 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/page-seo-save-core.ts +27 -0
- package/src/server/pages-core.ts +108 -11
- package/src/server/provider-secrets.ts +100 -4
- package/src/server/schemas.ts +74 -9
- package/src/site/SiteHeader.tsx +11 -5
- package/src/ui/Reveal.tsx +74 -9
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Usage: cms-platform-migrations [targetDir] [--check]
|
|
10
10
|
* targetDir defaults to ./supabase/migrations
|
|
11
|
-
* --check exits non-zero when files are missing
|
|
11
|
+
* --check exits non-zero when files are missing or their contents differ
|
|
12
12
|
*/
|
|
13
|
-
import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
|
|
13
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
14
14
|
import { dirname, join, resolve } from "node:path";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
|
|
@@ -32,18 +32,39 @@ const canonical = readdirSync(source)
|
|
|
32
32
|
|
|
33
33
|
const present = new Set(existsSync(target) ? readdirSync(target) : []);
|
|
34
34
|
const missing = canonical.filter((f) => !present.has(f));
|
|
35
|
+
const different = canonical.filter(
|
|
36
|
+
(f) =>
|
|
37
|
+
present.has(f) &&
|
|
38
|
+
!readFileSync(join(source, f)).equals(readFileSync(join(target, f))),
|
|
39
|
+
);
|
|
35
40
|
|
|
36
|
-
if (missing.length === 0) {
|
|
41
|
+
if (missing.length === 0 && different.length === 0) {
|
|
37
42
|
console.log(`Up to date: ${canonical.length} canonical migrations already present.`);
|
|
38
43
|
process.exit(0);
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
if (checkOnly) {
|
|
42
|
-
|
|
43
|
-
|
|
47
|
+
if (missing.length > 0) {
|
|
48
|
+
console.error(`Missing ${missing.length} canonical migrations:`);
|
|
49
|
+
for (const f of missing) console.error(` ${f}`);
|
|
50
|
+
}
|
|
51
|
+
if (different.length > 0) {
|
|
52
|
+
console.error(`Different contents for ${different.length} canonical migrations:`);
|
|
53
|
+
for (const f of different) console.error(` ${f}`);
|
|
54
|
+
}
|
|
44
55
|
process.exit(1);
|
|
45
56
|
}
|
|
46
57
|
|
|
47
58
|
mkdirSync(target, { recursive: true });
|
|
48
59
|
for (const f of missing) copyFileSync(join(source, f), join(target, f));
|
|
49
|
-
|
|
60
|
+
if (missing.length > 0) {
|
|
61
|
+
console.log(`Copied ${missing.length} migration(s) into ${target}. Apply them with your migration tooling.`);
|
|
62
|
+
}
|
|
63
|
+
if (different.length > 0) {
|
|
64
|
+
console.error(
|
|
65
|
+
"Existing migrations with different contents were not overwritten. " +
|
|
66
|
+
"If a timestamp has been applied, add a new corrective migration; otherwise replace the local copy explicitly.",
|
|
67
|
+
);
|
|
68
|
+
for (const f of different) console.error(` ${f}`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { useEffect, useState } from "react";
|
|
2
2
|
import { toast } from "sonner";
|
|
3
3
|
|
|
4
|
+
import { EDIT_LOCK_TYPES } from "../cms/edit-lock";
|
|
5
|
+
import { EditLockBanner, EditLockFence, useEditLock } from "./EditLock";
|
|
6
|
+
|
|
4
7
|
/** One editable company field. */
|
|
5
8
|
export type ContactField = { key: string; label: string; type?: string };
|
|
6
9
|
|
|
@@ -26,7 +29,9 @@ export type ContactDetailsDeps = {
|
|
|
26
29
|
loading?: boolean;
|
|
27
30
|
/** Fields to render; defaults to `CONTACT_FIELDS`. */
|
|
28
31
|
fields?: ContactField[];
|
|
29
|
-
saveSection: (input: {
|
|
32
|
+
saveSection: (input: {
|
|
33
|
+
data: { key: string; data: unknown; editSessionId?: string };
|
|
34
|
+
}) => Promise<unknown>;
|
|
30
35
|
refresh: () => Promise<unknown> | void;
|
|
31
36
|
};
|
|
32
37
|
|
|
@@ -44,6 +49,11 @@ export function ContactDetailsView({ deps }: { deps: ContactDetailsDeps }) {
|
|
|
44
49
|
const [form, setForm] = useState<Record<string, string>>(defaults);
|
|
45
50
|
const [saving, setSaving] = useState(false);
|
|
46
51
|
|
|
52
|
+
const lock = useEditLock({
|
|
53
|
+
resourceType: EDIT_LOCK_TYPES.contentSection,
|
|
54
|
+
resourceId: "company",
|
|
55
|
+
});
|
|
56
|
+
|
|
47
57
|
useEffect(() => {
|
|
48
58
|
if (stored && typeof stored === "object") setForm({ ...defaults, ...stored });
|
|
49
59
|
}, [stored, defaults]);
|
|
@@ -53,7 +63,9 @@ export function ContactDetailsView({ deps }: { deps: ContactDetailsDeps }) {
|
|
|
53
63
|
async function onSave() {
|
|
54
64
|
setSaving(true);
|
|
55
65
|
try {
|
|
56
|
-
await saveSection({
|
|
66
|
+
await saveSection({
|
|
67
|
+
data: { key: "company", data: form, editSessionId: lock.sessionId },
|
|
68
|
+
});
|
|
57
69
|
await refresh();
|
|
58
70
|
toast.success("Contact details saved");
|
|
59
71
|
} catch (e) {
|
|
@@ -73,7 +85,9 @@ export function ContactDetailsView({ deps }: { deps: ContactDetailsDeps }) {
|
|
|
73
85
|
for search engines.
|
|
74
86
|
</p>
|
|
75
87
|
|
|
76
|
-
<
|
|
88
|
+
<EditLockBanner lock={lock} className="mt-8" />
|
|
89
|
+
|
|
90
|
+
<EditLockFence lock={lock} className="mt-10 grid gap-5 sm:grid-cols-2">
|
|
77
91
|
{fields.map((f) => (
|
|
78
92
|
<label key={f.key} className="block">
|
|
79
93
|
<span className={lbl}>{f.label}</span>
|
|
@@ -85,13 +99,13 @@ export function ContactDetailsView({ deps }: { deps: ContactDetailsDeps }) {
|
|
|
85
99
|
/>
|
|
86
100
|
</label>
|
|
87
101
|
))}
|
|
88
|
-
</
|
|
102
|
+
</EditLockFence>
|
|
89
103
|
|
|
90
104
|
<div className="mt-10 flex items-center gap-4">
|
|
91
105
|
<button
|
|
92
106
|
type="button"
|
|
93
107
|
onClick={onSave}
|
|
94
|
-
disabled={saving || !!loading}
|
|
108
|
+
disabled={saving || !!loading || lock.blocked}
|
|
95
109
|
className="bg-ink px-6 py-3 text-xs uppercase tracking-[0.22em] text-paper disabled:opacity-50"
|
|
96
110
|
>
|
|
97
111
|
{saving ? "Saving..." : "Save details"}
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editing locks in the browser.
|
|
3
|
+
*
|
|
4
|
+
* Three pieces, deliberately small:
|
|
5
|
+
*
|
|
6
|
+
* - `EditLockProvider` — the host mounts it once, around the backoffice, with
|
|
7
|
+
* functions that call its own server endpoints. Nothing else needs wiring.
|
|
8
|
+
* - `useEditLock` — every editing screen calls it with the resource it has
|
|
9
|
+
* open. It takes the lock, keeps it alive, and gives it back.
|
|
10
|
+
* - `EditLockBanner` — what the blocked editor sees.
|
|
11
|
+
*
|
|
12
|
+
* With no provider mounted the hook reports "not enforced" and every editor
|
|
13
|
+
* behaves exactly as it did before locks existed, which is what keeps an
|
|
14
|
+
* existing installation working while its host catches up.
|
|
15
|
+
*
|
|
16
|
+
* None of this is the protection itself. The server refuses the write; this is
|
|
17
|
+
* how a person finds out before typing for ten minutes.
|
|
18
|
+
*/
|
|
19
|
+
import {
|
|
20
|
+
createContext,
|
|
21
|
+
useCallback,
|
|
22
|
+
useContext,
|
|
23
|
+
useEffect,
|
|
24
|
+
useMemo,
|
|
25
|
+
useRef,
|
|
26
|
+
useState,
|
|
27
|
+
type ReactNode,
|
|
28
|
+
} from "react";
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
asEditLockState,
|
|
32
|
+
claimEditSessionId,
|
|
33
|
+
DEFAULT_LOCK_TTL_SECONDS,
|
|
34
|
+
describeLock,
|
|
35
|
+
editSessionId,
|
|
36
|
+
heartbeatIntervalMs,
|
|
37
|
+
isBlocked,
|
|
38
|
+
lockHolderLabel,
|
|
39
|
+
secondsRemaining,
|
|
40
|
+
type EditLockRef,
|
|
41
|
+
type EditLockState,
|
|
42
|
+
} from "../cms/edit-lock";
|
|
43
|
+
|
|
44
|
+
export type { EditLockRef, EditLockState } from "../cms/edit-lock";
|
|
45
|
+
|
|
46
|
+
/** What the host must provide, each call reaching its own server function. */
|
|
47
|
+
export type EditLockApi = {
|
|
48
|
+
acquire: (
|
|
49
|
+
input: EditLockRef & { sessionId: string; ttlSeconds?: number },
|
|
50
|
+
) => Promise<EditLockState>;
|
|
51
|
+
/** Defaults to `acquire`, which the server treats as a renewal. */
|
|
52
|
+
renew?: (
|
|
53
|
+
input: EditLockRef & { sessionId: string; ttlSeconds?: number },
|
|
54
|
+
) => Promise<EditLockState>;
|
|
55
|
+
release: (input: EditLockRef & { sessionId: string }) => Promise<unknown>;
|
|
56
|
+
status?: (
|
|
57
|
+
input: EditLockRef & { sessionId?: string },
|
|
58
|
+
) => Promise<EditLockState>;
|
|
59
|
+
/** Superadmin recovery for a lock whose owner is unreachable. */
|
|
60
|
+
steal?: (
|
|
61
|
+
input: EditLockRef & { sessionId: string; ttlSeconds?: number },
|
|
62
|
+
) => Promise<EditLockState>;
|
|
63
|
+
/** Whether to offer the take-over button at all. */
|
|
64
|
+
canSteal?: boolean;
|
|
65
|
+
/** Lifetime to ask for. The server clamps it. */
|
|
66
|
+
ttlSeconds?: number;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** The five things a host's lock endpoint has to answer. */
|
|
70
|
+
export type EditLockAction = "acquire" | "renew" | "release" | "status" | "steal";
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build the whole api from one call into the host's own transport.
|
|
74
|
+
*
|
|
75
|
+
* The five functions differ only in the word they send, so a host that writes
|
|
76
|
+
* them by hand writes the same thing five times. Give this a function that
|
|
77
|
+
* reaches its server however it already does — a server function, `fetch`, a
|
|
78
|
+
* react-query mutation — and it returns the api the provider wants:
|
|
79
|
+
*
|
|
80
|
+
* Keep the returned object stable (for example with `useMemo`) because its
|
|
81
|
+
* identity controls each editor's acquire/release effect.
|
|
82
|
+
*
|
|
83
|
+
* On the server, `editLockActionCore` is the matching half. Nothing here knows
|
|
84
|
+
* what the transport is or which database is behind it.
|
|
85
|
+
*/
|
|
86
|
+
export function createEditLockApi(
|
|
87
|
+
call: (
|
|
88
|
+
action: EditLockAction,
|
|
89
|
+
input: EditLockRef & { sessionId: string; ttlSeconds?: number },
|
|
90
|
+
) => Promise<unknown>,
|
|
91
|
+
options: { canSteal?: boolean; ttlSeconds?: number } = {},
|
|
92
|
+
): EditLockApi {
|
|
93
|
+
const send =
|
|
94
|
+
(action: EditLockAction) =>
|
|
95
|
+
async (input: EditLockRef & { sessionId?: string; ttlSeconds?: number }) =>
|
|
96
|
+
asEditLockState(
|
|
97
|
+
await call(action, { ...input, sessionId: input.sessionId ?? "" }),
|
|
98
|
+
);
|
|
99
|
+
return {
|
|
100
|
+
acquire: send("acquire"),
|
|
101
|
+
renew: send("renew"),
|
|
102
|
+
release: send("release"),
|
|
103
|
+
status: send("status"),
|
|
104
|
+
// Offered only when the host says so: the database refuses anyone but a
|
|
105
|
+
// superadmin, and a button that always fails is worse than no button.
|
|
106
|
+
...(options.canSteal ? { steal: send("steal") } : {}),
|
|
107
|
+
canSteal: options.canSteal ?? false,
|
|
108
|
+
...(options.ttlSeconds == null ? {} : { ttlSeconds: options.ttlSeconds }),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const ApiContext = createContext<EditLockApi | null>(null);
|
|
113
|
+
|
|
114
|
+
export function EditLockProvider({
|
|
115
|
+
api,
|
|
116
|
+
children,
|
|
117
|
+
}: {
|
|
118
|
+
api: EditLockApi | null;
|
|
119
|
+
children: ReactNode;
|
|
120
|
+
}) {
|
|
121
|
+
return <ApiContext.Provider value={api}>{children}</ApiContext.Provider>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function useEditLockApi(): EditLockApi | null {
|
|
125
|
+
return useContext(ApiContext);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type EditLockHandle = {
|
|
129
|
+
/** The editor may change things. False while identity is being checked or another holder blocks it. */
|
|
130
|
+
editable: boolean;
|
|
131
|
+
/** The editor must remain read-only, including during the identity handshake. */
|
|
132
|
+
blocked: boolean;
|
|
133
|
+
/** A lock is actually being kept. False with no provider, or on an installation without the migration. */
|
|
134
|
+
enforced: boolean;
|
|
135
|
+
state: EditLockState | null;
|
|
136
|
+
/** Pass to save calls as `editSessionId` so the server can tell it is us. */
|
|
137
|
+
sessionId: string;
|
|
138
|
+
/** One sentence for the person who is blocked. Empty when they are not. */
|
|
139
|
+
message: string;
|
|
140
|
+
holder: string;
|
|
141
|
+
/** Seconds until the blocking lock lapses on its own. */
|
|
142
|
+
expiresInSeconds: number | null;
|
|
143
|
+
busy: boolean;
|
|
144
|
+
error: string | null;
|
|
145
|
+
refresh: () => Promise<void>;
|
|
146
|
+
release: () => Promise<void>;
|
|
147
|
+
takeOver: () => Promise<void>;
|
|
148
|
+
canTakeOver: boolean;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export type UseEditLockOptions = {
|
|
152
|
+
/** Skip while the resource id is still loading, or in a read-only screen. */
|
|
153
|
+
enabled?: boolean;
|
|
154
|
+
ttlSeconds?: number;
|
|
155
|
+
/** Told when the lock is lost mid-edit, e.g. to warn before saving. */
|
|
156
|
+
onLost?: (state: EditLockState) => void;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const IDLE: EditLockState = { locked: false, held: false };
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* One queue per resource and session, shared by every hook instance.
|
|
163
|
+
*
|
|
164
|
+
* Closing an editor and opening it again — a route change, or React's own
|
|
165
|
+
* double-invoked effects in development — releases from the old screen and
|
|
166
|
+
* acquires from the new one with the *same* session id. Those are two
|
|
167
|
+
* independent requests, and a release that happens to land second deletes a
|
|
168
|
+
* lock the editor believes it holds; nothing notices until the next heartbeat,
|
|
169
|
+
* a minute or so later, by which time somebody else may have taken it. The
|
|
170
|
+
* queue makes the server see the order the browser issued them in.
|
|
171
|
+
*/
|
|
172
|
+
const queues = new Map<string, Promise<unknown>>();
|
|
173
|
+
|
|
174
|
+
function lockQueueKey(resourceType: string, resourceId: string, sessionId: string): string {
|
|
175
|
+
return [resourceType, resourceId, sessionId].join("\u0000");
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function serialize<T>(key: string, work: () => Promise<T>): Promise<T> {
|
|
179
|
+
const previous = queues.get(key) ?? Promise.resolve();
|
|
180
|
+
// Run whether the one before succeeded or failed: a failed release must not
|
|
181
|
+
// strand every later call for this resource.
|
|
182
|
+
const result = previous.then(work, work);
|
|
183
|
+
const tail = result.then(
|
|
184
|
+
() => undefined,
|
|
185
|
+
() => undefined,
|
|
186
|
+
);
|
|
187
|
+
queues.set(key, tail);
|
|
188
|
+
void tail.then(() => {
|
|
189
|
+
if (queues.get(key) === tail) queues.delete(key);
|
|
190
|
+
});
|
|
191
|
+
return result;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Hold one resource for as long as this screen is open.
|
|
196
|
+
*
|
|
197
|
+
* The session id lives in `sessionStorage`, so reloading the page keeps the
|
|
198
|
+
* lock the editor already had instead of locking them out of their own work,
|
|
199
|
+
* while a second tab gets its own id and is told about the first.
|
|
200
|
+
*/
|
|
201
|
+
export function useEditLock(
|
|
202
|
+
ref: { resourceType: string; resourceId?: string | null },
|
|
203
|
+
options: UseEditLockOptions = {},
|
|
204
|
+
): EditLockHandle {
|
|
205
|
+
const api = useEditLockApi();
|
|
206
|
+
const { enabled = true, onLost } = options;
|
|
207
|
+
const ttlSeconds =
|
|
208
|
+
options.ttlSeconds ?? api?.ttlSeconds ?? DEFAULT_LOCK_TTL_SECONDS;
|
|
209
|
+
|
|
210
|
+
// Keep a valid id available for backward-compatible, provider-free screens.
|
|
211
|
+
// When enforcement is eligible the screen remains fenced until the live-
|
|
212
|
+
// document handshake has checked that sessionStorage was not copied.
|
|
213
|
+
const initialSessionId = useMemo(() => editSessionId(), []);
|
|
214
|
+
const [sessionId, setSessionId] = useState(initialSessionId);
|
|
215
|
+
const [sessionReady, setSessionReady] = useState(false);
|
|
216
|
+
const [stateResult, setStateResult] = useState<{
|
|
217
|
+
activation: object;
|
|
218
|
+
state: EditLockState;
|
|
219
|
+
} | null>(null);
|
|
220
|
+
const [busy, setBusy] = useState(false);
|
|
221
|
+
const [errorResult, setErrorResult] = useState<{
|
|
222
|
+
activation: object;
|
|
223
|
+
message: string;
|
|
224
|
+
} | null>(null);
|
|
225
|
+
|
|
226
|
+
const resourceType = ref.resourceType;
|
|
227
|
+
const resourceId = ref.resourceId ?? "";
|
|
228
|
+
const eligible =
|
|
229
|
+
Boolean(api) && enabled && resourceType !== "" && resourceId !== "";
|
|
230
|
+
const active = eligible && sessionReady;
|
|
231
|
+
const queueKey = lockQueueKey(resourceType, resourceId, sessionId);
|
|
232
|
+
// A fresh token for every activation prevents A -> B -> A navigation from
|
|
233
|
+
// reviving A's old answer after its cleanup has already released that lock.
|
|
234
|
+
// It also fences provider, eligibility and TTL changes before effects run.
|
|
235
|
+
const activation = useMemo(
|
|
236
|
+
() => ({}),
|
|
237
|
+
[api, eligible, queueKey, ttlSeconds],
|
|
238
|
+
);
|
|
239
|
+
const state =
|
|
240
|
+
stateResult?.activation === activation ? stateResult.state : null;
|
|
241
|
+
const error =
|
|
242
|
+
errorResult?.activation === activation ? errorResult.message : null;
|
|
243
|
+
|
|
244
|
+
// Only the newest request may write state: a heartbeat that overtakes a
|
|
245
|
+
// take-over must not resurrect the answer it was already given.
|
|
246
|
+
const turn = useRef(0);
|
|
247
|
+
const lost = useRef(false);
|
|
248
|
+
const onLostRef = useRef(onLost);
|
|
249
|
+
onLostRef.current = onLost;
|
|
250
|
+
|
|
251
|
+
useEffect(() => {
|
|
252
|
+
let stopped = false;
|
|
253
|
+
void claimEditSessionId().then((claimed) => {
|
|
254
|
+
if (stopped) return;
|
|
255
|
+
setSessionId(claimed || initialSessionId);
|
|
256
|
+
setSessionReady(true);
|
|
257
|
+
});
|
|
258
|
+
return () => {
|
|
259
|
+
stopped = true;
|
|
260
|
+
};
|
|
261
|
+
}, [initialSessionId]);
|
|
262
|
+
|
|
263
|
+
const run = useCallback(
|
|
264
|
+
async (
|
|
265
|
+
call: (a: EditLockApi) => Promise<EditLockState>,
|
|
266
|
+
{ showBusy = true }: { showBusy?: boolean } = {},
|
|
267
|
+
) => {
|
|
268
|
+
if (!api || !active) return;
|
|
269
|
+
const mine = ++turn.current;
|
|
270
|
+
if (showBusy) setBusy(true);
|
|
271
|
+
try {
|
|
272
|
+
const next = await serialize(queueKey, () => call(api));
|
|
273
|
+
if (turn.current !== mine) return;
|
|
274
|
+
setStateResult({ activation, state: next });
|
|
275
|
+
setErrorResult(null);
|
|
276
|
+
if (isBlocked(next) && !lost.current) {
|
|
277
|
+
lost.current = true;
|
|
278
|
+
onLostRef.current?.(next);
|
|
279
|
+
} else if (!isBlocked(next)) {
|
|
280
|
+
lost.current = false;
|
|
281
|
+
}
|
|
282
|
+
} catch (e) {
|
|
283
|
+
if (turn.current !== mine) return;
|
|
284
|
+
// A lock we cannot reach must not stop anyone working. The server is
|
|
285
|
+
// still the authority on whether the save lands.
|
|
286
|
+
setErrorResult({
|
|
287
|
+
activation,
|
|
288
|
+
message: e instanceof Error ? e.message : String(e),
|
|
289
|
+
});
|
|
290
|
+
} finally {
|
|
291
|
+
// A newer non-busy heartbeat may supersede a manual request. Whichever
|
|
292
|
+
// turn wins must clear the older spinner as well as its own.
|
|
293
|
+
if (turn.current === mine) setBusy(false);
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
[api, active, queueKey, activation],
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
/* Take it, keep it, give it back. */
|
|
300
|
+
useEffect(() => {
|
|
301
|
+
if (!api || !active) {
|
|
302
|
+
setStateResult(null);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
let stopped = false;
|
|
306
|
+
const input = { resourceType, resourceId, sessionId, ttlSeconds };
|
|
307
|
+
|
|
308
|
+
const beat = (showBusy: boolean) => {
|
|
309
|
+
if (stopped) return;
|
|
310
|
+
void run((a) => (a.renew ?? a.acquire)(input), { showBusy });
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
void run((a) => a.acquire(input));
|
|
314
|
+
const timer = setInterval(
|
|
315
|
+
() => beat(false),
|
|
316
|
+
heartbeatIntervalMs(ttlSeconds),
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
// A backgrounded/bfcached tab may have lost the lock while its timers were
|
|
320
|
+
// suspended. Fence its stale answer before asking the server again.
|
|
321
|
+
const revalidate = () => {
|
|
322
|
+
setStateResult(null);
|
|
323
|
+
setErrorResult(null);
|
|
324
|
+
beat(false);
|
|
325
|
+
};
|
|
326
|
+
const onVisible = () => {
|
|
327
|
+
if (document.visibilityState === "visible") revalidate();
|
|
328
|
+
};
|
|
329
|
+
const onPageShow = (event: PageTransitionEvent) => {
|
|
330
|
+
if (event.persisted) revalidate();
|
|
331
|
+
};
|
|
332
|
+
let fullDocumentLeaving = false;
|
|
333
|
+
const onPageHide = (event: PageTransitionEvent) => {
|
|
334
|
+
fullDocumentLeaving = !event.persisted;
|
|
335
|
+
};
|
|
336
|
+
document.addEventListener("visibilitychange", onVisible);
|
|
337
|
+
window.addEventListener("pageshow", onPageShow);
|
|
338
|
+
window.addEventListener("pagehide", onPageHide);
|
|
339
|
+
|
|
340
|
+
return () => {
|
|
341
|
+
stopped = true;
|
|
342
|
+
turn.current++;
|
|
343
|
+
clearInterval(timer);
|
|
344
|
+
document.removeEventListener("visibilitychange", onVisible);
|
|
345
|
+
window.removeEventListener("pageshow", onPageShow);
|
|
346
|
+
window.removeEventListener("pagehide", onPageHide);
|
|
347
|
+
// Leaving the editor frees the resource immediately rather than after
|
|
348
|
+
// the TTL. Full-document teardown deliberately relies on expiry instead:
|
|
349
|
+
// an old pagehide release can race a reload's acquire in another JS realm.
|
|
350
|
+
if (!fullDocumentLeaving) {
|
|
351
|
+
void serialize(queueKey, async () => api.release(input)).catch(() => {});
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
}, [api, active, resourceType, resourceId, sessionId, ttlSeconds, queueKey, run]);
|
|
355
|
+
|
|
356
|
+
const refresh = useCallback(async () => {
|
|
357
|
+
const input = { resourceType, resourceId, sessionId, ttlSeconds };
|
|
358
|
+
// A status reply saying "free" does not reserve anything. Acquire first,
|
|
359
|
+
// then enable editing only from the authoritative result of that attempt.
|
|
360
|
+
await run((a) => a.acquire(input));
|
|
361
|
+
}, [run, resourceType, resourceId, sessionId, ttlSeconds]);
|
|
362
|
+
|
|
363
|
+
const release = useCallback(async () => {
|
|
364
|
+
if (!api || !active) return;
|
|
365
|
+
turn.current++;
|
|
366
|
+
await serialize(queueKey, async () =>
|
|
367
|
+
api.release({ resourceType, resourceId, sessionId }),
|
|
368
|
+
).catch(() => {});
|
|
369
|
+
setStateResult({ activation, state: IDLE });
|
|
370
|
+
}, [api, active, resourceType, resourceId, sessionId, queueKey, activation]);
|
|
371
|
+
|
|
372
|
+
const takeOver = useCallback(async () => {
|
|
373
|
+
await run((a) => {
|
|
374
|
+
if (!a.steal)
|
|
375
|
+
throw new Error("Taking over an editing lock is not available here.");
|
|
376
|
+
return a.steal({ resourceType, resourceId, sessionId, ttlSeconds });
|
|
377
|
+
});
|
|
378
|
+
}, [run, resourceType, resourceId, sessionId, ttlSeconds]);
|
|
379
|
+
|
|
380
|
+
const identityPending = eligible && !sessionReady;
|
|
381
|
+
const acquisitionPending = active && state === null && error === null;
|
|
382
|
+
const blocked =
|
|
383
|
+
identityPending || acquisitionPending || (active && isBlocked(state));
|
|
384
|
+
return {
|
|
385
|
+
editable: !blocked,
|
|
386
|
+
blocked,
|
|
387
|
+
enforced: active && state !== null && state.locked,
|
|
388
|
+
state,
|
|
389
|
+
sessionId,
|
|
390
|
+
message: blocked ? describeLock(state) : "",
|
|
391
|
+
holder: lockHolderLabel(state),
|
|
392
|
+
expiresInSeconds: blocked ? secondsRemaining(state) : null,
|
|
393
|
+
busy: busy || identityPending || acquisitionPending,
|
|
394
|
+
error,
|
|
395
|
+
refresh,
|
|
396
|
+
release,
|
|
397
|
+
takeOver,
|
|
398
|
+
canTakeOver:
|
|
399
|
+
active &&
|
|
400
|
+
isBlocked(state) &&
|
|
401
|
+
Boolean(api?.steal) &&
|
|
402
|
+
api?.canSteal !== false,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const BlockedContext = createContext(false);
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* True when this part of the screen is inside a fence somebody else holds.
|
|
410
|
+
*
|
|
411
|
+
* A disabled `fieldset` stops form controls and buttons, but the browser
|
|
412
|
+
* happily goes on editing a `contenteditable` region inside one — which is
|
|
413
|
+
* every rich-text field in the page builder. Anything that edits outside a
|
|
414
|
+
* form control asks this and turns itself off.
|
|
415
|
+
*/
|
|
416
|
+
export function useEditLockBlocked(): boolean {
|
|
417
|
+
return useContext(BlockedContext);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Makes everything inside read-only while somebody else holds the lock.
|
|
422
|
+
*
|
|
423
|
+
* A disabled `fieldset` rather than an overlay: the content stays visible,
|
|
424
|
+
* selectable and scrollable — a blocked editor is meant to be able to *read* —
|
|
425
|
+
* while every control inside stops responding, including the drag handles,
|
|
426
|
+
* which are buttons. The server is what actually refuses the write; this only
|
|
427
|
+
* spares someone ten minutes of typing.
|
|
428
|
+
*/
|
|
429
|
+
export function EditLockFence({
|
|
430
|
+
lock,
|
|
431
|
+
children,
|
|
432
|
+
className = "",
|
|
433
|
+
}: {
|
|
434
|
+
lock: EditLockHandle;
|
|
435
|
+
children: ReactNode;
|
|
436
|
+
className?: string;
|
|
437
|
+
}) {
|
|
438
|
+
return (
|
|
439
|
+
<fieldset
|
|
440
|
+
disabled={lock.blocked}
|
|
441
|
+
aria-busy={lock.busy || undefined}
|
|
442
|
+
className={`min-w-0 border-0 p-0 m-0 ${lock.blocked ? "opacity-70" : ""} ${className}`.trim()}
|
|
443
|
+
>
|
|
444
|
+
<BlockedContext.Provider value={lock.blocked}>{children}</BlockedContext.Provider>
|
|
445
|
+
</fieldset>
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* What a blocked editor sees. Renders nothing when they are not blocked, so a
|
|
451
|
+
* screen can mount it unconditionally.
|
|
452
|
+
*/
|
|
453
|
+
export function EditLockBanner({
|
|
454
|
+
lock,
|
|
455
|
+
className = "",
|
|
456
|
+
}: {
|
|
457
|
+
lock: EditLockHandle;
|
|
458
|
+
className?: string;
|
|
459
|
+
}) {
|
|
460
|
+
// The fence also blocks during the short tab-identity handshake, but that is
|
|
461
|
+
// progress rather than another editor holding the resource and needs no
|
|
462
|
+
// read-only warning.
|
|
463
|
+
if (!lock.blocked || !isBlocked(lock.state)) return null;
|
|
464
|
+
const seconds = lock.expiresInSeconds;
|
|
465
|
+
return (
|
|
466
|
+
<div
|
|
467
|
+
role="status"
|
|
468
|
+
className={`border border-amber-300 bg-amber-50 p-4 text-sm text-amber-900 ${className}`.trim()}
|
|
469
|
+
>
|
|
470
|
+
<div className="text-[0.65rem] uppercase tracking-[0.22em] text-amber-700">
|
|
471
|
+
Read only
|
|
472
|
+
</div>
|
|
473
|
+
<p className="mt-2">{lock.message}</p>
|
|
474
|
+
{seconds !== null ? (
|
|
475
|
+
<p className="mt-1 text-xs text-amber-700">
|
|
476
|
+
{seconds > 0
|
|
477
|
+
? `The lock lapses on its own in about ${Math.max(1, Math.round(seconds / 60))} min if it is not renewed.`
|
|
478
|
+
: "The lock has lapsed — check again to start editing."}
|
|
479
|
+
</p>
|
|
480
|
+
) : null}
|
|
481
|
+
<div className="mt-3 flex flex-wrap gap-3">
|
|
482
|
+
<button
|
|
483
|
+
type="button"
|
|
484
|
+
onClick={() => void lock.refresh()}
|
|
485
|
+
disabled={lock.busy}
|
|
486
|
+
className="border border-amber-400 bg-white px-3 py-1 text-xs text-ink disabled:opacity-50"
|
|
487
|
+
>
|
|
488
|
+
{lock.busy ? "Checking…" : "Check again"}
|
|
489
|
+
</button>
|
|
490
|
+
{lock.canTakeOver ? (
|
|
491
|
+
<button
|
|
492
|
+
type="button"
|
|
493
|
+
onClick={() => {
|
|
494
|
+
const who = lock.holder;
|
|
495
|
+
if (
|
|
496
|
+
typeof window !== "undefined" &&
|
|
497
|
+
!window.confirm(
|
|
498
|
+
`Take over editing from ${who}? Any unsaved work of theirs will be lost.`,
|
|
499
|
+
)
|
|
500
|
+
)
|
|
501
|
+
return;
|
|
502
|
+
void lock.takeOver();
|
|
503
|
+
}}
|
|
504
|
+
disabled={lock.busy}
|
|
505
|
+
className="border border-amber-400 bg-white px-3 py-1 text-xs text-ink disabled:opacity-50"
|
|
506
|
+
>
|
|
507
|
+
Take over
|
|
508
|
+
</button>
|
|
509
|
+
) : null}
|
|
510
|
+
</div>
|
|
511
|
+
</div>
|
|
512
|
+
);
|
|
513
|
+
}
|