@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.90.0-m7.10",
|
|
4
4
|
"description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"./blocks": "./src/blocks/index.ts",
|
|
31
31
|
"./blocks/types": "./src/blocks/types.ts",
|
|
32
32
|
"./blocks/countdown-typography": "./src/blocks/countdown-typography.ts",
|
|
33
|
+
"./blocks/optional-content": "./src/blocks/optional-content.ts",
|
|
33
34
|
"./blocks/icons": "./src/blocks/icons.ts",
|
|
34
35
|
"./blocks/block-props-context": "./src/blocks/block-props-context.tsx",
|
|
35
36
|
"./blocks/components/*": "./src/blocks/components/*.tsx",
|
|
@@ -49,6 +50,8 @@
|
|
|
49
50
|
"./trips/TripSectionEditor": "./src/trips/TripSectionEditor.tsx",
|
|
50
51
|
"./navigation": "./src/navigation/index.ts",
|
|
51
52
|
"./forms/FormRenderer": "./src/forms/FormRenderer.tsx",
|
|
53
|
+
"./forms/success": "./src/forms/success-listeners.ts",
|
|
54
|
+
"./forms/submission-result": "./src/forms/submission-result.ts",
|
|
52
55
|
"./forms/conditions": "./src/forms/conditions.ts",
|
|
53
56
|
"./forms/field-sets": "./src/forms/field-sets.ts",
|
|
54
57
|
"./forms/form-audit": "./src/forms/form-audit.ts",
|
|
@@ -64,6 +67,7 @@
|
|
|
64
67
|
"./media/storage": "./src/media/storage.ts",
|
|
65
68
|
"./media/usage-types": "./src/media/usage-types.ts",
|
|
66
69
|
"./cms/version-diff": "./src/cms/version-diff.ts",
|
|
70
|
+
"./cms/edit-lock": "./src/cms/edit-lock.ts",
|
|
67
71
|
"./cms/shared-draft-store": "./src/cms/shared-draft-store.ts",
|
|
68
72
|
"./http/cache-policy": "./src/http/cache-policy.ts",
|
|
69
73
|
"./http/compression": "./src/http/compression.ts",
|
|
@@ -150,6 +154,9 @@
|
|
|
150
154
|
"./server/media-library-core": "./src/server/media-library-core.ts",
|
|
151
155
|
"./server/admin-core": "./src/server/admin-core.ts",
|
|
152
156
|
"./server/pages-core": "./src/server/pages-core.ts",
|
|
157
|
+
"./server/edit-locks-core": "./src/server/edit-locks-core.ts",
|
|
158
|
+
"./server/edit-lock-adapter": "./src/server/edit-lock-adapter.ts",
|
|
159
|
+
"./server/edit-lock-supabase-adapter": "./src/server/edit-lock-supabase-adapter.ts",
|
|
153
160
|
"./server/published-cache": "./src/server/published-cache.ts",
|
|
154
161
|
"./server/resolve-globals": "./src/server/resolve-globals.ts",
|
|
155
162
|
"./server/content-sections-core": "./src/server/content-sections-core.ts",
|
|
@@ -169,6 +176,7 @@
|
|
|
169
176
|
"./backoffice/routes/media-forms": "./src/backoffice/routes/media-forms.tsx",
|
|
170
177
|
"./backoffice/inspectors": "./src/backoffice/inspectors/index.tsx",
|
|
171
178
|
"./backoffice/PageBuilderView": "./src/backoffice/PageBuilderView.tsx",
|
|
179
|
+
"./backoffice/EditLock": "./src/backoffice/EditLock.tsx",
|
|
172
180
|
"./backoffice/SectionEditorView": "./src/backoffice/SectionEditorView.tsx",
|
|
173
181
|
"./backoffice/DashboardView": "./src/backoffice/DashboardView.tsx",
|
|
174
182
|
"./backoffice/ResetPasswordView": "./src/backoffice/ResetPasswordView.tsx",
|
|
@@ -179,6 +187,12 @@
|
|
|
179
187
|
"./site/font-bundle": "./src/site/font-bundle.ts",
|
|
180
188
|
"./admin/version-core": "./src/admin/version-core.ts",
|
|
181
189
|
"./config/package-owned-modules": "./src/config/package-owned-modules.ts",
|
|
190
|
+
"./modules": "./src/modules/index.ts",
|
|
191
|
+
"./popups/host": "./src/popups/host.ts",
|
|
192
|
+
"./popups/cms-bridges": "./src/popups/cms-bridges.ts",
|
|
193
|
+
"./popups/register": "./src/popups/register.ts",
|
|
194
|
+
"./popups/runtime": "./src/popups/runtime.tsx",
|
|
195
|
+
"./popups/admin": "./src/popups/admin.tsx",
|
|
182
196
|
"./server/schema-drift": "./src/server/schema-drift.ts",
|
|
183
197
|
"./server/media-focus-core": "./src/server/media-focus-core.ts",
|
|
184
198
|
"./server/media-meta-core": "./src/server/media-meta-core.ts",
|
|
@@ -213,6 +227,8 @@
|
|
|
213
227
|
"./backoffice/IndexingStatusCard": "./src/backoffice/IndexingStatusCard.tsx",
|
|
214
228
|
"./util/use-hash-scroll": "./src/util/use-hash-scroll.ts",
|
|
215
229
|
"./site/site-url": "./src/site/site-url.ts",
|
|
230
|
+
"./site/host": "./src/site/host.ts",
|
|
231
|
+
"./site/locale-domains": "./src/site/locale-domains.ts",
|
|
216
232
|
"./site/SiteHeader": "./src/site/SiteHeader.tsx",
|
|
217
233
|
"./site/SiteFooter": "./src/site/SiteFooter.tsx",
|
|
218
234
|
"./server/schemas": "./src/server/schemas.ts",
|
|
@@ -222,7 +238,11 @@
|
|
|
222
238
|
"./site/routes/section-preview": "./src/site/routes/section-preview.tsx",
|
|
223
239
|
"./media/priority": "./src/media/priority.tsx",
|
|
224
240
|
"./media/preload": "./src/media/preload.ts",
|
|
225
|
-
"./ui/NearViewport": "./src/ui/NearViewport.tsx"
|
|
241
|
+
"./ui/NearViewport": "./src/ui/NearViewport.tsx",
|
|
242
|
+
"./server/webhook-url-guard": "./src/server/webhook-url-guard.ts",
|
|
243
|
+
"./server/popup-optin-webhook-core": "./src/server/popup-optin-webhook-core.ts",
|
|
244
|
+
"./server/popup-optin-trigger": "./src/server/popup-optin-trigger.ts",
|
|
245
|
+
"./popups/destinations": "./src/popups/destinations.tsx"
|
|
226
246
|
},
|
|
227
247
|
"dependencies": {
|
|
228
248
|
"fflate": "^0.8.3"
|
|
@@ -250,7 +270,8 @@
|
|
|
250
270
|
"@dnd-kit/core": ">=6",
|
|
251
271
|
"@dnd-kit/sortable": ">=3",
|
|
252
272
|
"@dnd-kit/utilities": ">=3",
|
|
253
|
-
"zod": ">=3"
|
|
273
|
+
"zod": ">=3",
|
|
274
|
+
"@bygghammar/lightweight-popup": ">=0.2.0-m7.8"
|
|
254
275
|
},
|
|
255
276
|
"peerDependenciesMeta": {
|
|
256
277
|
"@supabase/supabase-js": {
|
|
@@ -294,6 +315,9 @@
|
|
|
294
315
|
},
|
|
295
316
|
"@tiptap/extension-table": {
|
|
296
317
|
"optional": true
|
|
318
|
+
},
|
|
319
|
+
"@bygghammar/lightweight-popup": {
|
|
320
|
+
"optional": true
|
|
297
321
|
}
|
|
298
322
|
},
|
|
299
323
|
"bin": {
|
|
@@ -312,6 +336,7 @@
|
|
|
312
336
|
"check": "bun run typecheck && bun run test && bun run verify:pack"
|
|
313
337
|
},
|
|
314
338
|
"devDependencies": {
|
|
339
|
+
"@bygghammar/lightweight-popup": "0.2.0-m7.8",
|
|
315
340
|
"@dnd-kit/core": "^6.3.1",
|
|
316
341
|
"@dnd-kit/sortable": "^10.0.0",
|
|
317
342
|
"@dnd-kit/utilities": "^3.2.2",
|
|
@@ -319,6 +344,8 @@
|
|
|
319
344
|
"@tanstack/react-query": "^5.101.1",
|
|
320
345
|
"@tanstack/react-router": "1.170.18",
|
|
321
346
|
"@tanstack/react-start": "1.168.32",
|
|
347
|
+
"@testing-library/dom": "^10.4.0",
|
|
348
|
+
"@testing-library/react": "^16.3.0",
|
|
322
349
|
"@tiptap/extension-highlight": "^3.29.2",
|
|
323
350
|
"@tiptap/extension-image": "^3.29.2",
|
|
324
351
|
"@tiptap/extension-subscript": "^3.29.2",
|
|
@@ -333,6 +360,7 @@
|
|
|
333
360
|
"@types/node": "^22.10.2",
|
|
334
361
|
"@types/react": "19.2.17",
|
|
335
362
|
"@types/react-dom": "19.2.3",
|
|
363
|
+
"jsdom": "^26.1.0",
|
|
336
364
|
"lucide-react": "^0.575.0",
|
|
337
365
|
"react": "^19.2.0",
|
|
338
366
|
"react-dom": "^19.2.0",
|
|
@@ -131,10 +131,11 @@ helper comes from \`@bygghammar/cms-platform\`.
|
|
|
131
131
|
Next steps:
|
|
132
132
|
|
|
133
133
|
1. \`npm install @bygghammar/cms-platform\`
|
|
134
|
-
2.
|
|
135
|
-
3.
|
|
136
|
-
4.
|
|
137
|
-
5.
|
|
134
|
+
2. Copy the canonical migrations: \`npx cms-platform-migrations\`
|
|
135
|
+
3. Apply \`supabase/migrations\` with the site's normal staging-first migration workflow.
|
|
136
|
+
4. Fill in \`src/lib/backoffice/host.tsx\` with this site's server functions.
|
|
137
|
+
5. Add the thin route wrappers under \`src/routes/${backofficePath.replace("/", "")}.*.tsx\`.
|
|
138
|
+
6. Set \`VITE_PUBLIC_SITE_URL\` per environment; never store it in the database.
|
|
138
139
|
`;
|
|
139
140
|
|
|
140
141
|
const existing = existsSync(root) ? await readdir(root) : [];
|
|
@@ -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"}
|
|
@@ -2,6 +2,16 @@ import { useState } from "react";
|
|
|
2
2
|
import { toast } from "sonner";
|
|
3
3
|
import { Trash2, Plus, Check, X } from "lucide-react";
|
|
4
4
|
|
|
5
|
+
import {
|
|
6
|
+
adminHostImpact,
|
|
7
|
+
adminHostImpactMessage,
|
|
8
|
+
domainChangeProblemMessage,
|
|
9
|
+
validateDomainChange,
|
|
10
|
+
type AdminDomainChange,
|
|
11
|
+
type DomainChangeProblem,
|
|
12
|
+
type EffectiveAdminHost,
|
|
13
|
+
} from "../site/locale-domains";
|
|
14
|
+
|
|
5
15
|
/** One language-to-domain mapping. */
|
|
6
16
|
export type LocaleDomainRow = {
|
|
7
17
|
locale: string;
|
|
@@ -15,15 +25,58 @@ export type DomainsDeps = {
|
|
|
15
25
|
domains: LocaleDomainRow[];
|
|
16
26
|
locales: readonly string[];
|
|
17
27
|
defaultLocale: string;
|
|
18
|
-
/** Path back to the
|
|
28
|
+
/** Path back to the Admin overview, e.g. `/kontoret/admin`. */
|
|
19
29
|
backPath: string;
|
|
20
30
|
/** Example domain shown as the input placeholder. */
|
|
21
31
|
domainPlaceholder?: string;
|
|
32
|
+
/**
|
|
33
|
+
* The hostname that actually serves the backoffice, and why.
|
|
34
|
+
*
|
|
35
|
+
* Answered by the server, because the deciding input is `SITE_URL` — a value
|
|
36
|
+
* the browser cannot read, and one a screen that guesses it would state to
|
|
37
|
+
* an administrator as fact about their own install. Undefined when the host
|
|
38
|
+
* registered no way to ask: the screen then says nothing about the hostname
|
|
39
|
+
* instead of inventing an answer, and everything else still works.
|
|
40
|
+
*/
|
|
41
|
+
adminHost?: EffectiveAdminHost;
|
|
42
|
+
/** Path the backoffice is served under, for the admin-domain explanation. */
|
|
43
|
+
backofficePath?: string;
|
|
22
44
|
saveDomain: (input: { data: LocaleDomainRow }) => Promise<unknown>;
|
|
23
45
|
deleteDomain: (input: { data: { locale: string } }) => Promise<unknown>;
|
|
24
46
|
refresh: () => Promise<unknown> | void;
|
|
25
47
|
};
|
|
26
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Why the backoffice is reached on the hostname it is reached on.
|
|
51
|
+
*
|
|
52
|
+
* Worth spelling out because the Admin checkbox looks like the whole answer
|
|
53
|
+
* and is not: on a self-hosted install `SITE_URL` outranks it, which is also
|
|
54
|
+
* what makes a bad row recoverable.
|
|
55
|
+
*
|
|
56
|
+
* The fallbacks say "no usable row" rather than "no row" because a row flagged
|
|
57
|
+
* before the domain validator existed can hold a value that is not a hostname.
|
|
58
|
+
* Such a row is skipped, so the backoffice falls back exactly as if nothing
|
|
59
|
+
* were flagged — and telling the administrator nothing is flagged, while the
|
|
60
|
+
* checkbox they can see is ticked, reads as a bug in the screen rather than as
|
|
61
|
+
* the reason to go and fix the row.
|
|
62
|
+
*/
|
|
63
|
+
function adminHostExplanation(info: EffectiveAdminHost): string {
|
|
64
|
+
switch (info.source) {
|
|
65
|
+
case "site-url":
|
|
66
|
+
return "is set by SITE_URL on this deployment, which overrides the Admin checkbox below. Change SITE_URL to move the backoffice.";
|
|
67
|
+
case "database":
|
|
68
|
+
return "is the domain flagged as the admin domain below.";
|
|
69
|
+
case "request-host":
|
|
70
|
+
return "is the address you opened, kept as-is because no usable row is flagged as the admin domain. Flag one below to designate it.";
|
|
71
|
+
case "default-mapping":
|
|
72
|
+
return "is the default domain, used because no usable row is flagged as the admin domain.";
|
|
73
|
+
case "first-mapping":
|
|
74
|
+
return "is the first configured domain, because no usable row is flagged as the admin domain and none is marked default.";
|
|
75
|
+
case "localhost":
|
|
76
|
+
return "is the local address, because nothing is configured yet.";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
27
80
|
/** Host-agnostic languages & domains screen. */
|
|
28
81
|
export function DomainsView({ deps }: { deps: DomainsDeps }) {
|
|
29
82
|
const {
|
|
@@ -31,43 +84,80 @@ export function DomainsView({ deps }: { deps: DomainsDeps }) {
|
|
|
31
84
|
locales,
|
|
32
85
|
defaultLocale: DEFAULT_LOCALE,
|
|
33
86
|
backPath,
|
|
34
|
-
domainPlaceholder = "
|
|
87
|
+
domainPlaceholder = "example.com",
|
|
88
|
+
adminHost,
|
|
89
|
+
backofficePath = "/kontoret",
|
|
35
90
|
saveDomain: save,
|
|
36
91
|
deleteDomain: del,
|
|
37
92
|
refresh,
|
|
38
93
|
} = deps;
|
|
39
94
|
const [busy, setBusy] = useState(false);
|
|
40
95
|
const [editing, setEditing] = useState<LocaleDomainRow | null>(null);
|
|
96
|
+
const [problem, setProblem] = useState<DomainChangeProblem | null>(null);
|
|
41
97
|
const [draft, setDraft] = useState<Partial<LocaleDomainRow>>({
|
|
42
|
-
locale: DEFAULT_LOCALE,
|
|
43
98
|
domain: "",
|
|
44
99
|
isDefault: false,
|
|
45
100
|
isAdminDomain: false,
|
|
46
101
|
});
|
|
47
102
|
|
|
103
|
+
// One row per language, so adding can only offer the languages that have
|
|
104
|
+
// none. Offering all of them made "Add" an upsert: picking a configured
|
|
105
|
+
// language and saving replaced that mapping's domain without ever saying so.
|
|
106
|
+
const configured = new Set(domains.map((d) => d.locale));
|
|
107
|
+
const available = locales.filter((l) => !configured.has(l));
|
|
108
|
+
const suggested = available.includes(DEFAULT_LOCALE) ? DEFAULT_LOCALE : available[0];
|
|
109
|
+
const addLocale = draft.locale && available.includes(draft.locale) ? draft.locale : suggested;
|
|
110
|
+
const locale = editing ? editing.locale : addLocale;
|
|
111
|
+
const canAdd = available.length > 0;
|
|
112
|
+
|
|
48
113
|
const reset = () => {
|
|
49
114
|
setEditing(null);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
115
|
+
setProblem(null);
|
|
116
|
+
setDraft({ domain: "", isDefault: false, isAdminDomain: false });
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Whether a change may go ahead, having said so when it may not.
|
|
121
|
+
*
|
|
122
|
+
* Two different things, deliberately kept apart. A change the server will
|
|
123
|
+
* refuse is refused here — stated as an error, with no dialog implying it
|
|
124
|
+
* could proceed. A change that merely moves the backoffice to another
|
|
125
|
+
* hostname is valid, and gets a confirmation naming both hostnames instead.
|
|
126
|
+
*/
|
|
127
|
+
const allow = (change: AdminDomainChange): boolean => {
|
|
128
|
+
const blocked = validateDomainChange(domains, change, {
|
|
129
|
+
siteUrlAuthoritative: adminHost?.siteUrlAuthoritative === true,
|
|
130
|
+
});
|
|
131
|
+
setProblem(blocked);
|
|
132
|
+
if (blocked) {
|
|
133
|
+
toast.error(domainChangeProblemMessage(blocked));
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const impact = adminHostImpact(domains, change, {
|
|
138
|
+
// With SITE_URL in charge no row change can move the backoffice, so this
|
|
139
|
+
// resolves to no impact and nothing is asked.
|
|
140
|
+
forcedHost: adminHost?.siteUrlAuthoritative ? adminHost.host : null,
|
|
141
|
+
requestHost: typeof window === "undefined" ? null : window.location.host,
|
|
142
|
+
defaultLocale: DEFAULT_LOCALE,
|
|
55
143
|
});
|
|
144
|
+
if (!impact) return true;
|
|
145
|
+
return confirm(adminHostImpactMessage(impact, { authoritative: adminHost !== undefined }));
|
|
56
146
|
};
|
|
57
147
|
|
|
58
148
|
const submit = async (e: React.FormEvent) => {
|
|
59
149
|
e.preventDefault();
|
|
60
|
-
if (!
|
|
150
|
+
if (!locale || !draft.domain) return;
|
|
151
|
+
const row: LocaleDomainRow = {
|
|
152
|
+
locale,
|
|
153
|
+
domain: draft.domain,
|
|
154
|
+
isDefault: !!draft.isDefault,
|
|
155
|
+
isAdminDomain: !!draft.isAdminDomain,
|
|
156
|
+
};
|
|
157
|
+
if (!allow({ kind: "save", ...row })) return;
|
|
61
158
|
setBusy(true);
|
|
62
159
|
try {
|
|
63
|
-
await save({
|
|
64
|
-
data: {
|
|
65
|
-
locale: draft.locale,
|
|
66
|
-
domain: draft.domain,
|
|
67
|
-
isDefault: !!draft.isDefault,
|
|
68
|
-
isAdminDomain: !!draft.isAdminDomain,
|
|
69
|
-
},
|
|
70
|
-
});
|
|
160
|
+
await save({ data: row });
|
|
71
161
|
await refresh();
|
|
72
162
|
toast.success("Domain saved");
|
|
73
163
|
reset();
|
|
@@ -78,11 +168,12 @@ export function DomainsView({ deps }: { deps: DomainsDeps }) {
|
|
|
78
168
|
}
|
|
79
169
|
};
|
|
80
170
|
|
|
81
|
-
const remove = async (
|
|
171
|
+
const remove = async (target: string) => {
|
|
172
|
+
if (!allow({ kind: "delete", locale: target })) return;
|
|
82
173
|
if (!confirm("Remove this domain mapping?")) return;
|
|
83
174
|
setBusy(true);
|
|
84
175
|
try {
|
|
85
|
-
await del({ data: { locale } });
|
|
176
|
+
await del({ data: { locale: target } });
|
|
86
177
|
await refresh();
|
|
87
178
|
toast.success("Domain removed");
|
|
88
179
|
} catch (err) {
|
|
@@ -94,9 +185,11 @@ export function DomainsView({ deps }: { deps: DomainsDeps }) {
|
|
|
94
185
|
|
|
95
186
|
const startEdit = (d: LocaleDomainRow) => {
|
|
96
187
|
setEditing(d);
|
|
188
|
+
setProblem(null);
|
|
97
189
|
setDraft({ ...d });
|
|
98
190
|
};
|
|
99
191
|
|
|
192
|
+
|
|
100
193
|
return (
|
|
101
194
|
<main className="p-6 md:p-10 w-full">
|
|
102
195
|
<div className="flex items-center justify-between">
|
|
@@ -114,9 +207,21 @@ export function DomainsView({ deps }: { deps: DomainsDeps }) {
|
|
|
114
207
|
|
|
115
208
|
<p className="mt-4 max-w-2xl text-sm text-ink/70">
|
|
116
209
|
Map each language to its own domain. Mark one domain as the default (fallback) and one as
|
|
117
|
-
the
|
|
210
|
+
the domain the backoffice is served from.
|
|
118
211
|
</p>
|
|
119
212
|
|
|
213
|
+
{adminHost && (
|
|
214
|
+
<div className="mt-6 max-w-2xl border border-ink/10 bg-ink/[0.02] px-4 py-3 text-sm text-ink/70">
|
|
215
|
+
<span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/50">
|
|
216
|
+
Backoffice hostname
|
|
217
|
+
</span>
|
|
218
|
+
<p className="mt-1">
|
|
219
|
+
<code className="font-mono text-ink">{adminHost.host}</code>{" "}
|
|
220
|
+
{adminHostExplanation(adminHost)}
|
|
221
|
+
</p>
|
|
222
|
+
</div>
|
|
223
|
+
)}
|
|
224
|
+
|
|
120
225
|
<section className="mt-10">
|
|
121
226
|
<h2 className="font-serif text-xl">Current domains</h2>
|
|
122
227
|
<div className="mt-4 overflow-x-auto border border-ink/10">
|
|
@@ -186,20 +291,33 @@ export function DomainsView({ deps }: { deps: DomainsDeps }) {
|
|
|
186
291
|
|
|
187
292
|
<section className="mt-12 max-w-xl">
|
|
188
293
|
<h2 className="font-serif text-xl">{editing ? "Edit domain" : "Add domain"}</h2>
|
|
294
|
+
{!editing && !canAdd ? (
|
|
295
|
+
<p className="mt-4 border border-ink/10 bg-ink/[0.02] px-4 py-3 text-sm text-ink/70">
|
|
296
|
+
Every language already has a domain. Use Edit on a row above to change one — adding
|
|
297
|
+
would only overwrite an existing mapping.
|
|
298
|
+
</p>
|
|
299
|
+
) : (
|
|
189
300
|
<form onSubmit={submit} className="mt-4 space-y-4">
|
|
190
301
|
<label className="block">
|
|
191
302
|
<span className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/60">Language</span>
|
|
192
303
|
<select
|
|
193
|
-
value={
|
|
304
|
+
value={locale}
|
|
305
|
+
disabled={editing !== null}
|
|
194
306
|
onChange={(e) => setDraft((d) => ({ ...d, locale: e.target.value }))}
|
|
195
|
-
className="mt-2 w-full border border-ink/15 bg-white px-4 py-3 text-ink focus:border-sea focus:outline-none"
|
|
307
|
+
className="mt-2 w-full border border-ink/15 bg-white px-4 py-3 text-ink focus:border-sea focus:outline-none disabled:cursor-not-allowed disabled:bg-ink/5 disabled:text-ink/60"
|
|
196
308
|
>
|
|
197
|
-
{
|
|
309
|
+
{(editing ? [editing.locale] : available).map((l) => (
|
|
198
310
|
<option key={l} value={l}>
|
|
199
311
|
{l.toUpperCase()}
|
|
200
312
|
</option>
|
|
201
313
|
))}
|
|
202
314
|
</select>
|
|
315
|
+
{editing !== null && (
|
|
316
|
+
<span className="mt-1 block text-xs text-ink/50">
|
|
317
|
+
The language identifies the mapping, so it cannot be changed here. To move this
|
|
318
|
+
domain to another language, remove this row and add it again.
|
|
319
|
+
</span>
|
|
320
|
+
)}
|
|
203
321
|
</label>
|
|
204
322
|
|
|
205
323
|
<label className="block">
|
|
@@ -213,7 +331,9 @@ export function DomainsView({ deps }: { deps: DomainsDeps }) {
|
|
|
213
331
|
className="mt-2 w-full border border-ink/15 bg-white px-4 py-3 text-ink placeholder:text-ink/40 focus:border-sea focus:outline-none"
|
|
214
332
|
/>
|
|
215
333
|
<span className="mt-1 block text-xs text-ink/50">
|
|
216
|
-
Enter the
|
|
334
|
+
Enter the hostname only — no https://, no path, no trailing slash. Whether you
|
|
335
|
+
include <code>www.</code> is kept as typed and used in generated links; requests
|
|
336
|
+
arriving on the other form still reach this language, so one row covers both.
|
|
217
337
|
</span>
|
|
218
338
|
</label>
|
|
219
339
|
|
|
@@ -234,10 +354,16 @@ export function DomainsView({ deps }: { deps: DomainsDeps }) {
|
|
|
234
354
|
onChange={(e) => setDraft((d) => ({ ...d, isAdminDomain: e.target.checked }))}
|
|
235
355
|
className="size-4 border-ink/30 text-sea focus:ring-sea"
|
|
236
356
|
/>
|
|
237
|
-
Admin domain (
|
|
357
|
+
Admin domain ({backofficePath} is served here)
|
|
238
358
|
</label>
|
|
239
359
|
</div>
|
|
240
360
|
|
|
361
|
+
{problem && (
|
|
362
|
+
<p role="alert" className="border border-red-600/30 bg-red-50 px-4 py-3 text-sm text-red-700">
|
|
363
|
+
{domainChangeProblemMessage(problem)}
|
|
364
|
+
</p>
|
|
365
|
+
)}
|
|
366
|
+
|
|
241
367
|
<div className="flex items-center gap-3 pt-4">
|
|
242
368
|
<button
|
|
243
369
|
type="submit"
|
|
@@ -258,6 +384,7 @@ export function DomainsView({ deps }: { deps: DomainsDeps }) {
|
|
|
258
384
|
)}
|
|
259
385
|
</div>
|
|
260
386
|
</form>
|
|
387
|
+
)}
|
|
261
388
|
</section>
|
|
262
389
|
</main>
|
|
263
390
|
);
|