@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
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
10
10
|
import { Download, ImageIcon, Images, RotateCcw, Save, Sparkles, X } from "lucide-react";
|
|
11
11
|
|
|
12
|
+
import { EDIT_LOCK_TYPES } from "../../cms/edit-lock";
|
|
13
|
+
import { EditLockBanner, EditLockFence, useEditLock } from "../EditLock";
|
|
12
14
|
import {
|
|
13
15
|
mergeMediaMetaIntoForm,
|
|
14
16
|
pageSeoPreviewCanonical,
|
|
@@ -27,6 +29,13 @@ export type PageSeoPanelViewProps = {
|
|
|
27
29
|
defaultLocale: string;
|
|
28
30
|
/** True when the page exists only in the CMS (no code registry entry). */
|
|
29
31
|
isCmsPage?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Page this record belongs to. Supplying it puts the screen under the shared
|
|
34
|
+
* editing lock, so two administrators cannot overwrite each other's SEO. A
|
|
35
|
+
* host that omits it keeps the previous behaviour (server-side enforcement
|
|
36
|
+
* still applies whenever the host passes the session id on to the save).
|
|
37
|
+
*/
|
|
38
|
+
pageKey?: string | null;
|
|
30
39
|
/** Current registry route for a locale, shown in the preview panel. */
|
|
31
40
|
registryRoute: (locale: string) => string;
|
|
32
41
|
/** Public origin used to build the canonical preview URL. */
|
|
@@ -35,10 +44,14 @@ export type PageSeoPanelViewProps = {
|
|
|
35
44
|
pageImages: readonly PageSeoImageCandidate[];
|
|
36
45
|
/** Saved media-library metadata for an image URL, or null when none exists. */
|
|
37
46
|
lookupMeta: (src: string) => PageSeoMediaMeta | null;
|
|
38
|
-
/**
|
|
39
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Persist the form. Return false when saving failed (host shows the error).
|
|
49
|
+
* The editing-session id is passed through so the server can tell this
|
|
50
|
+
* editor's own save apart from someone else's.
|
|
51
|
+
*/
|
|
52
|
+
onSave: (form: PageSeoFormShape, editSessionId?: string) => Promise<boolean>;
|
|
40
53
|
/** Remove the override; only enabled when `canReset`. */
|
|
41
|
-
onReset: () => Promise<void>;
|
|
54
|
+
onReset: (editSessionId?: string) => Promise<void>;
|
|
42
55
|
canReset?: boolean;
|
|
43
56
|
renderMediaPicker: (args: { onPick: (url: string) => void; onClose: () => void }) => ReactNode;
|
|
44
57
|
renderPageImagePicker: (args: {
|
|
@@ -58,6 +71,7 @@ export function PageSeoPanelView({
|
|
|
58
71
|
locales,
|
|
59
72
|
defaultLocale,
|
|
60
73
|
isCmsPage,
|
|
74
|
+
pageKey,
|
|
61
75
|
registryRoute,
|
|
62
76
|
originForLocale,
|
|
63
77
|
pageImages,
|
|
@@ -76,6 +90,7 @@ export function PageSeoPanelView({
|
|
|
76
90
|
const [picking, setPicking] = useState(false);
|
|
77
91
|
const [pickingOnPage, setPickingOnPage] = useState(false);
|
|
78
92
|
const [form, setForm] = useState<PageSeoFormShape>(initialForm);
|
|
93
|
+
const lock = useEditLock({ resourceType: EDIT_LOCK_TYPES.pageSeo, resourceId: pageKey });
|
|
79
94
|
|
|
80
95
|
useEffect(() => {
|
|
81
96
|
setForm(initialForm);
|
|
@@ -130,7 +145,7 @@ export function PageSeoPanelView({
|
|
|
130
145
|
const handleSave = async () => {
|
|
131
146
|
setBusy(true);
|
|
132
147
|
try {
|
|
133
|
-
if (await onSave(form)) setDirty(false);
|
|
148
|
+
if (await onSave(form, lock.sessionId)) setDirty(false);
|
|
134
149
|
} finally {
|
|
135
150
|
setBusy(false);
|
|
136
151
|
}
|
|
@@ -145,7 +160,7 @@ export function PageSeoPanelView({
|
|
|
145
160
|
if (!ask("Remove your changes and revert to the defaults from the codebase?")) return;
|
|
146
161
|
setBusy(true);
|
|
147
162
|
try {
|
|
148
|
-
await onReset();
|
|
163
|
+
await onReset(lock.sessionId);
|
|
149
164
|
} finally {
|
|
150
165
|
setBusy(false);
|
|
151
166
|
}
|
|
@@ -163,213 +178,215 @@ export function PageSeoPanelView({
|
|
|
163
178
|
|
|
164
179
|
return (
|
|
165
180
|
<div>
|
|
166
|
-
{
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
onClick={() => setActiveLocale(l)}
|
|
178
|
-
className={[
|
|
179
|
-
"px-4 py-2 text-xs uppercase tracking-[0.22em] border-b-2 -mb-px",
|
|
180
|
-
activeLocale === l
|
|
181
|
-
? "border-ink text-ink"
|
|
182
|
-
: "border-transparent text-ink/50 hover:text-ink",
|
|
183
|
-
].join(" ")}
|
|
184
|
-
>
|
|
185
|
-
{l}
|
|
186
|
-
</button>
|
|
187
|
-
))}
|
|
188
|
-
</div>
|
|
189
|
-
)}
|
|
190
|
-
|
|
191
|
-
<div className="mt-8 grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
192
|
-
<Field label="Slug" hint={`Empty = root for ${activeLocale}. E.g. "boka"`}>
|
|
193
|
-
<input
|
|
194
|
-
value={form.slugs[activeLocale] ?? ""}
|
|
195
|
-
onChange={(e) => updateLocalized("slugs", activeLocale, e.target.value)}
|
|
196
|
-
className="seo-input"
|
|
197
|
-
/>
|
|
198
|
-
</Field>
|
|
199
|
-
<Field label="Robots" hint="E.g. noindex,nofollow — empty = default">
|
|
200
|
-
<input
|
|
201
|
-
value={form.seo.robots}
|
|
202
|
-
onChange={(e) => updateString("robots", e.target.value)}
|
|
203
|
-
className="seo-input"
|
|
204
|
-
/>
|
|
205
|
-
</Field>
|
|
206
|
-
<Field label="Title" full>
|
|
207
|
-
<input
|
|
208
|
-
value={form.seo.title[activeLocale] ?? ""}
|
|
209
|
-
onChange={(e) => updateLocalized("title", activeLocale, e.target.value)}
|
|
210
|
-
className="seo-input"
|
|
211
|
-
/>
|
|
212
|
-
</Field>
|
|
213
|
-
<Field label="Meta description" full>
|
|
214
|
-
<textarea
|
|
215
|
-
rows={3}
|
|
216
|
-
value={form.seo.description[activeLocale] ?? ""}
|
|
217
|
-
onChange={(e) => updateLocalized("description", activeLocale, e.target.value)}
|
|
218
|
-
className="seo-input"
|
|
219
|
-
/>
|
|
220
|
-
</Field>
|
|
221
|
-
<Field label="OG title" hint="Share title (fallback: title)">
|
|
222
|
-
<input
|
|
223
|
-
value={form.seo.ogTitle[activeLocale] ?? ""}
|
|
224
|
-
onChange={(e) => updateLocalized("ogTitle", activeLocale, e.target.value)}
|
|
225
|
-
className="seo-input"
|
|
226
|
-
/>
|
|
227
|
-
</Field>
|
|
228
|
-
<Field label="OG description" hint="Share text (fallback: meta description)">
|
|
229
|
-
<input
|
|
230
|
-
value={form.seo.ogDescription[activeLocale] ?? ""}
|
|
231
|
-
onChange={(e) => updateLocalized("ogDescription", activeLocale, e.target.value)}
|
|
232
|
-
className="seo-input"
|
|
233
|
-
/>
|
|
234
|
-
</Field>
|
|
235
|
-
<Field label="OG image" hint="Pick from the media library or paste an absolute https URL">
|
|
236
|
-
<div className="space-y-2">
|
|
237
|
-
<div className="flex gap-2">
|
|
238
|
-
<input
|
|
239
|
-
value={form.seo.ogImageSrc}
|
|
240
|
-
onChange={(e) => updateString("ogImageSrc", e.target.value)}
|
|
241
|
-
className="seo-input"
|
|
242
|
-
placeholder="https://…"
|
|
243
|
-
/>
|
|
181
|
+
<EditLockBanner lock={lock} className="mb-6" />
|
|
182
|
+
<EditLockFence lock={lock}>
|
|
183
|
+
{isCmsPage && (
|
|
184
|
+
<p className="mb-4 text-xs text-ink/55">
|
|
185
|
+
This page was created in the page builder. Its URL and metadata are stored in the CMS —
|
|
186
|
+
set the slug below (empty = site root).
|
|
187
|
+
</p>
|
|
188
|
+
)}
|
|
189
|
+
{locales.length > 1 && (
|
|
190
|
+
<div className="flex gap-2 border-b border-ink/10">
|
|
191
|
+
{locales.map((l) => (
|
|
244
192
|
<button
|
|
245
|
-
|
|
246
|
-
onClick={() =>
|
|
247
|
-
className=
|
|
193
|
+
key={l}
|
|
194
|
+
onClick={() => setActiveLocale(l)}
|
|
195
|
+
className={[
|
|
196
|
+
"px-4 py-2 text-xs uppercase tracking-[0.22em] border-b-2 -mb-px",
|
|
197
|
+
activeLocale === l
|
|
198
|
+
? "border-ink text-ink"
|
|
199
|
+
: "border-transparent text-ink/50 hover:text-ink",
|
|
200
|
+
].join(" ")}
|
|
248
201
|
>
|
|
249
|
-
|
|
250
|
-
Choose
|
|
202
|
+
{l}
|
|
251
203
|
</button>
|
|
252
|
-
|
|
204
|
+
))}
|
|
205
|
+
</div>
|
|
206
|
+
)}
|
|
207
|
+
|
|
208
|
+
<div className="mt-8 grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
209
|
+
<Field label="Slug" hint={`Empty = root for ${activeLocale}. E.g. "boka"`}>
|
|
210
|
+
<input
|
|
211
|
+
value={form.slugs[activeLocale] ?? ""}
|
|
212
|
+
onChange={(e) => updateLocalized("slugs", activeLocale, e.target.value)}
|
|
213
|
+
className="seo-input"
|
|
214
|
+
/>
|
|
215
|
+
</Field>
|
|
216
|
+
<Field label="Robots" hint="E.g. noindex,nofollow — empty = default">
|
|
217
|
+
<input
|
|
218
|
+
value={form.seo.robots}
|
|
219
|
+
onChange={(e) => updateString("robots", e.target.value)}
|
|
220
|
+
className="seo-input"
|
|
221
|
+
/>
|
|
222
|
+
</Field>
|
|
223
|
+
<Field label="Title" full>
|
|
224
|
+
<input
|
|
225
|
+
value={form.seo.title[activeLocale] ?? ""}
|
|
226
|
+
onChange={(e) => updateLocalized("title", activeLocale, e.target.value)}
|
|
227
|
+
className="seo-input"
|
|
228
|
+
/>
|
|
229
|
+
</Field>
|
|
230
|
+
<Field label="Meta description" full>
|
|
231
|
+
<textarea
|
|
232
|
+
rows={3}
|
|
233
|
+
value={form.seo.description[activeLocale] ?? ""}
|
|
234
|
+
onChange={(e) => updateLocalized("description", activeLocale, e.target.value)}
|
|
235
|
+
className="seo-input"
|
|
236
|
+
/>
|
|
237
|
+
</Field>
|
|
238
|
+
<Field label="OG title" hint="Share title (fallback: title)">
|
|
239
|
+
<input
|
|
240
|
+
value={form.seo.ogTitle[activeLocale] ?? ""}
|
|
241
|
+
onChange={(e) => updateLocalized("ogTitle", activeLocale, e.target.value)}
|
|
242
|
+
className="seo-input"
|
|
243
|
+
/>
|
|
244
|
+
</Field>
|
|
245
|
+
<Field label="OG description" hint="Share text (fallback: meta description)">
|
|
246
|
+
<input
|
|
247
|
+
value={form.seo.ogDescription[activeLocale] ?? ""}
|
|
248
|
+
onChange={(e) => updateLocalized("ogDescription", activeLocale, e.target.value)}
|
|
249
|
+
className="seo-input"
|
|
250
|
+
/>
|
|
251
|
+
</Field>
|
|
252
|
+
<Field label="OG image" hint="Pick from the media library or paste an absolute https URL">
|
|
253
|
+
<div className="space-y-2">
|
|
254
|
+
<div className="flex gap-2">
|
|
255
|
+
<input
|
|
256
|
+
value={form.seo.ogImageSrc}
|
|
257
|
+
onChange={(e) => updateString("ogImageSrc", e.target.value)}
|
|
258
|
+
className="seo-input"
|
|
259
|
+
placeholder="https://…"
|
|
260
|
+
/>
|
|
253
261
|
<button
|
|
254
262
|
type="button"
|
|
255
|
-
onClick={() =>
|
|
256
|
-
className="inline-flex shrink-0 items-center border border-ink/20 px-
|
|
257
|
-
title="Clear"
|
|
263
|
+
onClick={() => setPicking(true)}
|
|
264
|
+
className="inline-flex shrink-0 items-center gap-1.5 border border-ink/20 px-3 text-[0.7rem] uppercase tracking-widest hover:border-sea"
|
|
258
265
|
>
|
|
259
|
-
<
|
|
266
|
+
<ImageIcon className="size-3.5" />
|
|
267
|
+
Choose
|
|
268
|
+
</button>
|
|
269
|
+
{form.seo.ogImageSrc ? (
|
|
270
|
+
<button
|
|
271
|
+
type="button"
|
|
272
|
+
onClick={() => updateString("ogImageSrc", "")}
|
|
273
|
+
className="inline-flex shrink-0 items-center border border-ink/20 px-2 text-ink/60 hover:border-sea"
|
|
274
|
+
title="Clear"
|
|
275
|
+
>
|
|
276
|
+
<X className="size-3.5" />
|
|
277
|
+
</button>
|
|
278
|
+
) : null}
|
|
279
|
+
</div>
|
|
280
|
+
<div className="flex flex-wrap gap-2">
|
|
281
|
+
<button
|
|
282
|
+
type="button"
|
|
283
|
+
disabled={!heroImage}
|
|
284
|
+
onClick={() => heroImage && onPickMedia(heroImage.url)}
|
|
285
|
+
className="inline-flex items-center gap-1.5 border border-ink/20 px-3 py-1.5 text-[0.7rem] uppercase tracking-widest hover:border-sea disabled:opacity-40"
|
|
286
|
+
title={heroImage?.url}
|
|
287
|
+
>
|
|
288
|
+
<Sparkles className="size-3.5" />
|
|
289
|
+
Use hero image
|
|
260
290
|
</button>
|
|
261
|
-
) : null}
|
|
262
|
-
</div>
|
|
263
|
-
<div className="flex flex-wrap gap-2">
|
|
264
|
-
<button
|
|
265
|
-
type="button"
|
|
266
|
-
disabled={!heroImage}
|
|
267
|
-
onClick={() => heroImage && onPickMedia(heroImage.url)}
|
|
268
|
-
className="inline-flex items-center gap-1.5 border border-ink/20 px-3 py-1.5 text-[0.7rem] uppercase tracking-widest hover:border-sea disabled:opacity-40"
|
|
269
|
-
title={heroImage?.url}
|
|
270
|
-
>
|
|
271
|
-
<Sparkles className="size-3.5" />
|
|
272
|
-
Use hero image
|
|
273
|
-
</button>
|
|
274
|
-
<button
|
|
275
|
-
type="button"
|
|
276
|
-
disabled={pageImages.length === 0}
|
|
277
|
-
onClick={() => setPickingOnPage(true)}
|
|
278
|
-
className="inline-flex items-center gap-1.5 border border-ink/20 px-3 py-1.5 text-[0.7rem] uppercase tracking-widest hover:border-sea disabled:opacity-40"
|
|
279
|
-
>
|
|
280
|
-
<Images className="size-3.5" />
|
|
281
|
-
Pick from this page ({pageImages.length})
|
|
282
|
-
</button>
|
|
283
|
-
</div>
|
|
284
|
-
{form.seo.ogImageSrc ? (
|
|
285
|
-
<div className="flex items-center gap-3">
|
|
286
|
-
<img
|
|
287
|
-
src={form.seo.ogImageSrc}
|
|
288
|
-
alt=""
|
|
289
|
-
className="h-16 w-28 border border-ink/10 object-cover"
|
|
290
|
-
/>
|
|
291
291
|
<button
|
|
292
292
|
type="button"
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
ok
|
|
297
|
-
? "Imported alt, OG title and description from the media library"
|
|
298
|
-
: "This image has no metadata saved in the media library yet",
|
|
299
|
-
);
|
|
300
|
-
}}
|
|
301
|
-
className="inline-flex items-center gap-1.5 border border-ink/20 px-3 py-1.5 text-[0.7rem] uppercase tracking-widest hover:border-sea"
|
|
293
|
+
disabled={pageImages.length === 0}
|
|
294
|
+
onClick={() => setPickingOnPage(true)}
|
|
295
|
+
className="inline-flex items-center gap-1.5 border border-ink/20 px-3 py-1.5 text-[0.7rem] uppercase tracking-widest hover:border-sea disabled:opacity-40"
|
|
302
296
|
>
|
|
303
|
-
<
|
|
304
|
-
|
|
297
|
+
<Images className="size-3.5" />
|
|
298
|
+
Pick from this page ({pageImages.length})
|
|
305
299
|
</button>
|
|
306
300
|
</div>
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
301
|
+
{form.seo.ogImageSrc ? (
|
|
302
|
+
<div className="flex items-center gap-3">
|
|
303
|
+
<img
|
|
304
|
+
src={form.seo.ogImageSrc}
|
|
305
|
+
alt=""
|
|
306
|
+
className="h-16 w-28 border border-ink/10 object-cover"
|
|
307
|
+
/>
|
|
308
|
+
<button
|
|
309
|
+
type="button"
|
|
310
|
+
onClick={() => {
|
|
311
|
+
const ok = importFromMedia(form.seo.ogImageSrc, true);
|
|
312
|
+
(ok ? notify.success : notify.info)(
|
|
313
|
+
ok
|
|
314
|
+
? "Imported alt, OG title and description from the media library"
|
|
315
|
+
: "This image has no metadata saved in the media library yet",
|
|
316
|
+
);
|
|
317
|
+
}}
|
|
318
|
+
className="inline-flex items-center gap-1.5 border border-ink/20 px-3 py-1.5 text-[0.7rem] uppercase tracking-widest hover:border-sea"
|
|
319
|
+
>
|
|
320
|
+
<Download className="size-3.5" />
|
|
321
|
+
Import image metadata
|
|
322
|
+
</button>
|
|
323
|
+
</div>
|
|
324
|
+
) : null}
|
|
325
|
+
</div>
|
|
326
|
+
</Field>
|
|
327
|
+
<Field label={`OG image alt (${activeLocale})`}>
|
|
328
|
+
<input
|
|
329
|
+
value={form.seo.ogImageAlt[activeLocale] ?? ""}
|
|
330
|
+
onChange={(e) => updateLocalized("ogImageAlt", activeLocale, e.target.value)}
|
|
331
|
+
className="seo-input"
|
|
332
|
+
/>
|
|
333
|
+
</Field>
|
|
334
|
+
<Field label="Keywords" full hint="Comma-separated list">
|
|
335
|
+
<input
|
|
336
|
+
value={form.seo.keywords[activeLocale] ?? ""}
|
|
337
|
+
onChange={(e) => updateLocalized("keywords", activeLocale, e.target.value)}
|
|
338
|
+
className="seo-input"
|
|
339
|
+
/>
|
|
340
|
+
</Field>
|
|
341
|
+
</div>
|
|
325
342
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
343
|
+
<div className="mt-10 border border-ink/10 bg-white p-6">
|
|
344
|
+
<div className="text-[0.65rem] uppercase tracking-[0.22em] text-ink/55">Preview</div>
|
|
345
|
+
<dl className="mt-4 grid grid-cols-1 md:grid-cols-[140px_1fr] gap-x-6 gap-y-2 text-sm">
|
|
346
|
+
<dt className="text-ink/50">Canonical</dt>
|
|
347
|
+
<dd className="font-mono text-ink/80 break-all">{previewCanonical}</dd>
|
|
348
|
+
<dt className="text-ink/50">Route</dt>
|
|
349
|
+
<dd className="font-mono text-ink/80">
|
|
350
|
+
{previewPath} (current registry: {registryRoute(activeLocale)})
|
|
351
|
+
</dd>
|
|
352
|
+
<dt className="text-ink/50"><title></dt>
|
|
353
|
+
<dd className="text-ink/80">
|
|
354
|
+
{form.seo.title[activeLocale] || <em className="text-ink/40">empty</em>}
|
|
355
|
+
</dd>
|
|
356
|
+
<dt className="text-ink/50">description</dt>
|
|
357
|
+
<dd className="text-ink/80">
|
|
358
|
+
{form.seo.description[activeLocale] || <em className="text-ink/40">empty</em>}
|
|
359
|
+
</dd>
|
|
360
|
+
</dl>
|
|
361
|
+
</div>
|
|
345
362
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
363
|
+
<div className="mt-8 flex flex-wrap items-center gap-3">
|
|
364
|
+
<button
|
|
365
|
+
onClick={handleSave}
|
|
366
|
+
disabled={busy || !dirty || lock.blocked}
|
|
367
|
+
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"
|
|
368
|
+
>
|
|
369
|
+
<Save className="size-4" />
|
|
370
|
+
{busy ? "Saving…" : "Save"}
|
|
371
|
+
</button>
|
|
372
|
+
<button
|
|
373
|
+
onClick={handleReset}
|
|
374
|
+
disabled={busy || !canReset || lock.blocked}
|
|
375
|
+
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-40"
|
|
376
|
+
>
|
|
377
|
+
<RotateCcw className="size-4" />
|
|
378
|
+
Reset to default
|
|
379
|
+
</button>
|
|
380
|
+
{dirty && (
|
|
381
|
+
<span className="text-xs text-sea uppercase tracking-[0.22em]">unsaved changes</span>
|
|
382
|
+
)}
|
|
383
|
+
</div>
|
|
367
384
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
385
|
+
{picking && renderMediaPicker({ onPick: onPickMedia, onClose: () => setPicking(false) })}
|
|
386
|
+
{pickingOnPage &&
|
|
387
|
+
renderPageImagePicker({ onPick: onPickMedia, onClose: () => setPickingOnPage(false) })}
|
|
371
388
|
|
|
372
|
-
|
|
389
|
+
<style>{`
|
|
373
390
|
.seo-input {
|
|
374
391
|
width: 100%;
|
|
375
392
|
background: white;
|
|
@@ -380,7 +397,8 @@ export function PageSeoPanelView({
|
|
|
380
397
|
color: var(--ink);
|
|
381
398
|
}
|
|
382
399
|
.seo-input:focus { border-color: var(--sea); }
|
|
383
|
-
|
|
400
|
+
`}</style>
|
|
401
|
+
</EditLockFence>
|
|
384
402
|
</div>
|
|
385
403
|
);
|
|
386
404
|
}
|
|
@@ -3,25 +3,25 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Returns the host the backoffice should be served from, or null when the
|
|
5
5
|
* current host is fine (local dev, Lovable preview, or already the admin host).
|
|
6
|
+
*
|
|
7
|
+
* This is canonical-host routing, not an authorization boundary — roles are.
|
|
8
|
+
* So every uncertain case returns null and stays put: a missing, blank or
|
|
9
|
+
* malformed admin domain must not bounce an administrator to a hostname that
|
|
10
|
+
* serves nothing, which is a lockout with no way back in.
|
|
6
11
|
*/
|
|
7
|
-
|
|
12
|
+
import { isValidCanonicalDomain, normalizeCanonicalDomain } from "../../site/host";
|
|
8
13
|
|
|
9
|
-
|
|
10
|
-
return value
|
|
11
|
-
.replace(/^https?:\/\//, "")
|
|
12
|
-
.replace(/\/+$/, "")
|
|
13
|
-
.replace(/^www\./, "")
|
|
14
|
-
.toLowerCase();
|
|
15
|
-
}
|
|
14
|
+
const DEV_HOST_SUFFIXES = [".lovable.app", ".lovableproject.com"];
|
|
16
15
|
|
|
17
16
|
/** True for hosts where the admin-domain guard must not apply. */
|
|
18
17
|
export function isDevelopmentHost(host: string): boolean {
|
|
19
|
-
const h =
|
|
18
|
+
const h = normalizeCanonicalDomain(host);
|
|
20
19
|
return (
|
|
21
20
|
DEV_HOST_SUFFIXES.some((suffix) => h.endsWith(suffix)) ||
|
|
22
21
|
h === "localhost" ||
|
|
23
|
-
h.
|
|
24
|
-
h
|
|
22
|
+
h.endsWith(".localhost") ||
|
|
23
|
+
h === "127.0.0.1" ||
|
|
24
|
+
h === "[::1]"
|
|
25
25
|
);
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -31,8 +31,21 @@ export function backofficeRedirectHost(
|
|
|
31
31
|
adminDomain: string | null | undefined,
|
|
32
32
|
): string | null {
|
|
33
33
|
if (!currentHost || isDevelopmentHost(currentHost)) return null;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
|
|
35
|
+
// The stored form is the destination — `www.` included when configured —
|
|
36
|
+
// and the comparison is against that exact hostname, not against an alias
|
|
37
|
+
// key. Apex and `www.` are aliases for *finding* the row; they are two
|
|
38
|
+
// different addresses to serve from, and the configured one is the answer.
|
|
39
|
+
// Folding them together here is what left `voyara.se` sitting on a
|
|
40
|
+
// deployment that had chosen `www.voyara.se`, cookies and all.
|
|
41
|
+
// Validated before normalizing, not after: normalizing strips the scheme, so
|
|
42
|
+
// `ftp://voyara.se` checked afterwards is the valid `voyara.se`.
|
|
43
|
+
const admin = isValidCanonicalDomain(adminDomain) ? normalizeCanonicalDomain(adminDomain ?? "") : "";
|
|
44
|
+
if (!admin) return null;
|
|
45
|
+
// A development host is never a destination either. `localhost` is what an
|
|
46
|
+
// unconfigured install resolves to, and sending a public request there is
|
|
47
|
+
// the lockout this guard exists to avoid.
|
|
48
|
+
if (isDevelopmentHost(admin)) return null;
|
|
49
|
+
if (normalizeCanonicalDomain(currentHost) === admin) return null;
|
|
37
50
|
return admin;
|
|
38
51
|
}
|
|
@@ -9,9 +9,14 @@ export type { BackofficeNavItem, BackofficeNavKey, BackofficeNavOptions } from "
|
|
|
9
9
|
export { backofficeRedirectHost, isDevelopmentHost } from "./domain-guard";
|
|
10
10
|
export {
|
|
11
11
|
FeatureDisabledNotice,
|
|
12
|
+
ModuleRoleNotice,
|
|
13
|
+
MY_ROLES_QUERY_KEY,
|
|
12
14
|
SITE_SETTINGS_QUERY_KEY,
|
|
13
15
|
useBackofficeNav,
|
|
14
16
|
useFeatureEnabled,
|
|
15
17
|
useFeatureGate,
|
|
18
|
+
useModuleGate,
|
|
19
|
+
useModuleRoleAllowed,
|
|
20
|
+
useMyRoles,
|
|
16
21
|
useResolvedSiteConfig,
|
|
17
22
|
} from "./use-resolved-config";
|
|
@@ -8,22 +8,23 @@
|
|
|
8
8
|
import type { LucideProps } from "lucide-react";
|
|
9
9
|
import {
|
|
10
10
|
Blocks,
|
|
11
|
-
Calendar,
|
|
12
|
-
ClipboardList,
|
|
13
11
|
FileText,
|
|
14
|
-
Globe,
|
|
15
|
-
GraduationCap,
|
|
16
12
|
ImageIcon,
|
|
17
13
|
Layers,
|
|
18
14
|
LayoutDashboard,
|
|
19
15
|
LayoutList,
|
|
20
|
-
MessageSquareQuote,
|
|
21
16
|
ShieldCheck,
|
|
22
17
|
} from "lucide-react";
|
|
23
18
|
|
|
24
19
|
import type { SiteConfig } from "../../config/site-config";
|
|
20
|
+
import { CMS_MODULE_LIST, type CmsModuleKey } from "../../config/modules";
|
|
25
21
|
import type { BackofficeNavigationItem } from "../../navigation";
|
|
26
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Sidebar keys: the always-present screens plus every module that declares a
|
|
25
|
+
* nav entry. Module keys are no longer repeated here — a module contributing a
|
|
26
|
+
* sidebar item is a fact about the module, kept in one place.
|
|
27
|
+
*/
|
|
27
28
|
export type BackofficeNavKey =
|
|
28
29
|
| "dashboard"
|
|
29
30
|
| "pages"
|
|
@@ -32,12 +33,8 @@ export type BackofficeNavKey =
|
|
|
32
33
|
| "sections"
|
|
33
34
|
| "media"
|
|
34
35
|
| "seo"
|
|
35
|
-
| "
|
|
36
|
-
|
|
|
37
|
-
| "courses"
|
|
38
|
-
| "forms"
|
|
39
|
-
| "reviews"
|
|
40
|
-
| "admin";
|
|
36
|
+
| "admin"
|
|
37
|
+
| CmsModuleKey;
|
|
41
38
|
|
|
42
39
|
export type BackofficeNavItem = BackofficeNavigationItem<LucideProps>;
|
|
43
40
|
|
|
@@ -51,7 +48,8 @@ type Definition = {
|
|
|
51
48
|
feature?: keyof SiteConfig["features"];
|
|
52
49
|
};
|
|
53
50
|
|
|
54
|
-
|
|
51
|
+
/** Screens every site has, regardless of which modules are on. */
|
|
52
|
+
const CORE: readonly Definition[] = [
|
|
55
53
|
{ key: "dashboard", path: "", label: "Dashboard", icon: LayoutDashboard, exact: true },
|
|
56
54
|
{ key: "pages", path: "/pages-builder", label: "Page builder", icon: Blocks },
|
|
57
55
|
{ key: "global-sections", path: "/global-sections", label: "Global Sections", icon: Layers },
|
|
@@ -59,17 +57,28 @@ const DEFINITIONS: readonly Definition[] = [
|
|
|
59
57
|
{ key: "sections", path: "/sections", label: "Shared content", icon: LayoutList },
|
|
60
58
|
{ key: "media", path: "/media", label: "Media", icon: ImageIcon },
|
|
61
59
|
{ key: "seo", path: "/seo", label: "SEO", icon: FileText },
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/** Entries contributed by modules, each gated on its own flag. */
|
|
63
|
+
const MODULE_ITEMS: readonly Definition[] = CMS_MODULE_LIST.flatMap((module) =>
|
|
64
|
+
module.nav === undefined
|
|
65
|
+
? []
|
|
66
|
+
: [
|
|
67
|
+
{
|
|
68
|
+
key: module.key,
|
|
69
|
+
path: module.nav.path,
|
|
70
|
+
label: module.nav.label,
|
|
71
|
+
icon: module.nav.icon,
|
|
72
|
+
...(module.nav.exact === undefined ? {} : { exact: module.nav.exact }),
|
|
73
|
+
feature: module.key,
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
// Admin stays last: it is the catch-all for everything a module did not claim.
|
|
79
|
+
const DEFINITIONS: readonly Definition[] = [
|
|
80
|
+
...CORE,
|
|
81
|
+
...MODULE_ITEMS,
|
|
73
82
|
{ key: "admin", path: "/admin", label: "Admin", icon: ShieldCheck },
|
|
74
83
|
];
|
|
75
84
|
|