@bygghammar/cms-platform 0.90.0-m7.9 → 0.91.0
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 +241 -0
- package/README.md +23 -1
- package/docs/GOOGLE-SHEETS.md +145 -0
- package/docs/LAYOUT-BLOCKS.md +125 -0
- package/docs/MEDIA-FOLDERS.md +90 -0
- package/docs/NEW-SITE.md +35 -0
- package/docs/PAGES.md +452 -0
- package/docs/SCHEDULING.md +130 -0
- package/docs/TYPOGRAPHY.md +86 -0
- package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
- package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
- package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
- package/package.json +5 -1
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/FormEditorView.tsx +10 -0
- package/src/backoffice/FormSettingsView.tsx +8 -0
- package/src/backoffice/MediaLibraryView.tsx +239 -21
- package/src/backoffice/NavigationView.tsx +43 -0
- package/src/backoffice/PageBuilderView.tsx +193 -36
- package/src/backoffice/PageEditorView.tsx +293 -57
- package/src/backoffice/PagesListView.tsx +294 -19
- package/src/backoffice/ScheduleFields.tsx +151 -0
- package/src/backoffice/SectionFormView.tsx +58 -0
- package/src/backoffice/StatusBadge.tsx +174 -0
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/TypographyPanel.tsx +308 -0
- package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
- package/src/backoffice/inspectors/blocks.tsx +149 -0
- package/src/backoffice/inspectors/fields.tsx +81 -0
- package/src/backoffice/inspectors/index.tsx +3 -0
- package/src/backoffice/media/ConfirmDialog.tsx +64 -4
- package/src/backoffice/routes/host.ts +31 -0
- package/src/backoffice/routes/index.tsx +140 -15
- package/src/backoffice/routes/media-forms.tsx +5 -0
- package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
- package/src/backoffice/seo/page-seo-form.ts +11 -1
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/block-children.tsx +40 -0
- package/src/blocks/block-tree.ts +378 -0
- package/src/blocks/components/IconGridBlock.tsx +76 -51
- package/src/blocks/components/LayoutBlock.tsx +100 -0
- package/src/blocks/countdown-time.ts +17 -87
- package/src/blocks/registry.tsx +21 -0
- package/src/blocks/types.ts +40 -0
- package/src/cms/page-publication.ts +377 -0
- package/src/cms/page-status.ts +152 -0
- package/src/cms/timezone.ts +181 -0
- package/src/cms/use-publication-boundary.ts +84 -0
- package/src/cms/use-schedule-clock.ts +43 -0
- package/src/cms/visibility-schedule.ts +267 -0
- package/src/config/modules.ts +34 -10
- package/src/config/site-config.ts +32 -0
- package/src/config/typography.ts +143 -0
- package/src/http/cache-policy.ts +49 -2
- package/src/media/folders.ts +183 -0
- package/src/media/use-media-upload.ts +21 -4
- package/src/navigation/index.ts +34 -2
- package/src/platform/contract.ts +52 -1
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +16 -0
- package/src/server/domains-core.ts +124 -107
- package/src/server/forms-delivery-core.ts +61 -10
- package/src/server/forms-submissions-core.ts +47 -0
- package/src/server/google-service-account.ts +325 -0
- package/src/server/google-sheets-core.ts +463 -0
- package/src/server/http/sitemap.ts +32 -8
- package/src/server/injections-core.ts +230 -49
- package/src/server/media-library-core.ts +27 -6
- package/src/server/pages-core.ts +680 -184
- package/src/server/published-cache.ts +25 -4
- package/src/server/resolve-globals.ts +6 -1
- package/src/server/schema-drift.ts +4 -0
- package/src/server/schemas.ts +15 -0
- package/src/site/PageRenderer.tsx +45 -12
- package/src/site/SiteFooter.tsx +44 -3
- package/src/site/SiteHeader.tsx +23 -2
- package/src/site/google-fonts.ts +191 -0
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes/public-page.tsx +12 -1
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/site/typography.tsx +75 -0
- package/src/tracking/injections.ts +52 -9
- package/src/ui/MobileCarousel.tsx +289 -0
|
@@ -57,6 +57,7 @@ import type {
|
|
|
57
57
|
PriceGroup,
|
|
58
58
|
IntroImageAspect,
|
|
59
59
|
CountdownProps,
|
|
60
|
+
LayoutProps,
|
|
60
61
|
} from "../../blocks/types";
|
|
61
62
|
import { INTRO_IMAGE_ASPECTS } from "../../blocks/types";
|
|
62
63
|
import { BLOCK_ICON_NAMES, resolveBlockIcon } from "../../blocks/icons";
|
|
@@ -67,6 +68,8 @@ import {
|
|
|
67
68
|
TextField,
|
|
68
69
|
MediaField,
|
|
69
70
|
MediaMultiField,
|
|
71
|
+
SelectField,
|
|
72
|
+
NumberField,
|
|
70
73
|
} from "./fields";
|
|
71
74
|
import { RichTextEditor } from "../../host";
|
|
72
75
|
import { useEditLockBlocked } from "../EditLock";
|
|
@@ -74,6 +77,13 @@ import { Plus, Trash2, ArrowUp, ArrowDown } from "lucide-react";
|
|
|
74
77
|
import type { LocalizedString, Locale } from "../../i18n";
|
|
75
78
|
import { SUPPORTED_LOCALES } from "../../i18n";
|
|
76
79
|
import { normalizeRevealMode, type RevealMode } from "../../ui/Reveal";
|
|
80
|
+
import {
|
|
81
|
+
clampAutoplayMs,
|
|
82
|
+
DEFAULT_AUTOPLAY_MS,
|
|
83
|
+
MIN_AUTOPLAY_MS,
|
|
84
|
+
MAX_AUTOPLAY_MS,
|
|
85
|
+
} from "../../ui/MobileCarousel";
|
|
86
|
+
import { LAYOUT_MAX_COLUMNS, LAYOUT_MIN_COLUMNS } from "../../blocks/block-tree";
|
|
77
87
|
|
|
78
88
|
export type BlockPatchProps = {
|
|
79
89
|
block: BlockInstance;
|
|
@@ -1881,6 +1891,43 @@ function IconGridInspector({ block, patch }: BlockPatchProps) {
|
|
|
1881
1891
|
</label>
|
|
1882
1892
|
<TextField label="Anchor id" value={p.anchor} onChange={(v) => update({ ...p, anchor: v })} />
|
|
1883
1893
|
</InspectorSection>
|
|
1894
|
+
<InspectorSection title="Mobile presentation">
|
|
1895
|
+
<SelectField
|
|
1896
|
+
label="On phones"
|
|
1897
|
+
value={p.mobileLayout ?? "stack"}
|
|
1898
|
+
options={[
|
|
1899
|
+
{ value: "stack", label: "Stack (one card per row)" },
|
|
1900
|
+
{ value: "slider", label: "Slider (swipe sideways)" },
|
|
1901
|
+
]}
|
|
1902
|
+
onChange={(v) => update({ ...p, mobileLayout: v as IconGridProps["mobileLayout"] })}
|
|
1903
|
+
hint="Tablet and desktop keep the column grid above."
|
|
1904
|
+
/>
|
|
1905
|
+
{p.mobileLayout === "slider" ? (
|
|
1906
|
+
<>
|
|
1907
|
+
<SelectField
|
|
1908
|
+
label="Autoplay"
|
|
1909
|
+
value={p.mobileAutoplay ? "on" : "off"}
|
|
1910
|
+
options={[
|
|
1911
|
+
{ value: "off", label: "Off" },
|
|
1912
|
+
{ value: "on", label: "On" },
|
|
1913
|
+
]}
|
|
1914
|
+
onChange={(v) => update({ ...p, mobileAutoplay: v === "on" })}
|
|
1915
|
+
hint="Readers who ask for reduced motion never see it move on its own."
|
|
1916
|
+
/>
|
|
1917
|
+
{p.mobileAutoplay ? (
|
|
1918
|
+
<NumberField
|
|
1919
|
+
label="Autoplay interval"
|
|
1920
|
+
value={Math.round(clampAutoplayMs(p.mobileAutoplayMs) / 1000)}
|
|
1921
|
+
min={MIN_AUTOPLAY_MS / 1000}
|
|
1922
|
+
max={MAX_AUTOPLAY_MS / 1000}
|
|
1923
|
+
suffix="seconds"
|
|
1924
|
+
onChange={(v) => update({ ...p, mobileAutoplayMs: clampAutoplayMs(v * 1000) })}
|
|
1925
|
+
hint={`Between ${MIN_AUTOPLAY_MS / 1000} and ${MAX_AUTOPLAY_MS / 1000} seconds. Default ${DEFAULT_AUTOPLAY_MS / 1000}.`}
|
|
1926
|
+
/>
|
|
1927
|
+
) : null}
|
|
1928
|
+
</>
|
|
1929
|
+
) : null}
|
|
1930
|
+
</InspectorSection>
|
|
1884
1931
|
<InspectorSection title={`Cards (${items.length})`}>
|
|
1885
1932
|
<div className="space-y-4">
|
|
1886
1933
|
{items.map((item, i) => {
|
|
@@ -2412,7 +2459,109 @@ function MediaSplitInspector({ block, patch }: BlockPatchProps) {
|
|
|
2412
2459
|
}
|
|
2413
2460
|
|
|
2414
2461
|
|
|
2462
|
+
/* ---------------- Layout (columns container) ---------------- */
|
|
2463
|
+
function LayoutInspector({ block, patch }: BlockPatchProps) {
|
|
2464
|
+
const p = (block.props ?? {}) as LayoutProps;
|
|
2465
|
+
const update = (next: LayoutProps) => patch(next as unknown as BlockProps);
|
|
2466
|
+
const desktop = p.columnsDesktop ?? 2;
|
|
2467
|
+
const desktopOptions = Array.from(
|
|
2468
|
+
{ length: LAYOUT_MAX_COLUMNS - LAYOUT_MIN_COLUMNS + 1 },
|
|
2469
|
+
(_, i) => LAYOUT_MIN_COLUMNS + i,
|
|
2470
|
+
);
|
|
2471
|
+
|
|
2472
|
+
return (
|
|
2473
|
+
<>
|
|
2474
|
+
<InspectorSection title="Columns">
|
|
2475
|
+
<SelectField
|
|
2476
|
+
label="Desktop columns"
|
|
2477
|
+
value={String(desktop)}
|
|
2478
|
+
options={desktopOptions.map((n) => ({ value: String(n), label: `${n} columns` }))}
|
|
2479
|
+
onChange={(v) =>
|
|
2480
|
+
update({ ...p, columnsDesktop: Number(v) as LayoutProps["columnsDesktop"] })
|
|
2481
|
+
}
|
|
2482
|
+
/>
|
|
2483
|
+
<SelectField
|
|
2484
|
+
label="Tablet columns"
|
|
2485
|
+
value={String(p.columnsTablet ?? Math.min(2, desktop))}
|
|
2486
|
+
options={[1, 2, 3, 4].map((n) => ({
|
|
2487
|
+
value: String(n),
|
|
2488
|
+
label: n === 1 ? "1 column (stacked)" : `${n} columns`,
|
|
2489
|
+
}))}
|
|
2490
|
+
onChange={(v) =>
|
|
2491
|
+
update({ ...p, columnsTablet: Number(v) as LayoutProps["columnsTablet"] })
|
|
2492
|
+
}
|
|
2493
|
+
/>
|
|
2494
|
+
<p className="text-[0.7rem] text-ink/50">
|
|
2495
|
+
Blocks are added to a column from the page outline: drag any block onto the column you
|
|
2496
|
+
want it in. Reducing the column count never deletes anything — leftover blocks move into
|
|
2497
|
+
the last column.
|
|
2498
|
+
</p>
|
|
2499
|
+
</InspectorSection>
|
|
2500
|
+
<InspectorSection title="Appearance">
|
|
2501
|
+
<SelectField
|
|
2502
|
+
label="Gap between columns"
|
|
2503
|
+
value={p.gap ?? "md"}
|
|
2504
|
+
options={[
|
|
2505
|
+
{ value: "none", label: "None" },
|
|
2506
|
+
{ value: "sm", label: "Small" },
|
|
2507
|
+
{ value: "md", label: "Medium" },
|
|
2508
|
+
{ value: "lg", label: "Large" },
|
|
2509
|
+
]}
|
|
2510
|
+
onChange={(v) => update({ ...p, gap: v as LayoutProps["gap"] })}
|
|
2511
|
+
/>
|
|
2512
|
+
<SelectField
|
|
2513
|
+
label="Vertical alignment"
|
|
2514
|
+
value={p.align ?? "stretch"}
|
|
2515
|
+
options={[
|
|
2516
|
+
{ value: "stretch", label: "Equal height" },
|
|
2517
|
+
{ value: "start", label: "Top" },
|
|
2518
|
+
{ value: "center", label: "Centre" },
|
|
2519
|
+
]}
|
|
2520
|
+
onChange={(v) => update({ ...p, align: v as LayoutProps["align"] })}
|
|
2521
|
+
/>
|
|
2522
|
+
<SelectField
|
|
2523
|
+
label="Background"
|
|
2524
|
+
value={p.tone ?? "transparent"}
|
|
2525
|
+
options={[
|
|
2526
|
+
{ value: "transparent", label: "Transparent" },
|
|
2527
|
+
{ value: "white", label: "White" },
|
|
2528
|
+
{ value: "paper", label: "Paper" },
|
|
2529
|
+
{ value: "ice", label: "Ice" },
|
|
2530
|
+
]}
|
|
2531
|
+
onChange={(v) => update({ ...p, tone: v as LayoutProps["tone"] })}
|
|
2532
|
+
/>
|
|
2533
|
+
<SelectField
|
|
2534
|
+
label="Vertical padding"
|
|
2535
|
+
value={p.padding ?? "none"}
|
|
2536
|
+
options={[
|
|
2537
|
+
{ value: "none", label: "None" },
|
|
2538
|
+
{ value: "compact", label: "Compact" },
|
|
2539
|
+
{ value: "normal", label: "Normal" },
|
|
2540
|
+
{ value: "roomy", label: "Roomy" },
|
|
2541
|
+
]}
|
|
2542
|
+
onChange={(v) => update({ ...p, padding: v as LayoutProps["padding"] })}
|
|
2543
|
+
/>
|
|
2544
|
+
<SelectField
|
|
2545
|
+
label="Width"
|
|
2546
|
+
value={p.contained === false ? "full" : "contained"}
|
|
2547
|
+
options={[
|
|
2548
|
+
{ value: "contained", label: "Inside the page container" },
|
|
2549
|
+
{ value: "full", label: "Full width" },
|
|
2550
|
+
]}
|
|
2551
|
+
onChange={(v) => update({ ...p, contained: v !== "full" })}
|
|
2552
|
+
/>
|
|
2553
|
+
<TextField
|
|
2554
|
+
label="Anchor id"
|
|
2555
|
+
value={p.anchor ?? ""}
|
|
2556
|
+
onChange={(v) => update({ ...p, anchor: v })}
|
|
2557
|
+
/>
|
|
2558
|
+
</InspectorSection>
|
|
2559
|
+
</>
|
|
2560
|
+
);
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2415
2563
|
export {
|
|
2564
|
+
LayoutInspector,
|
|
2416
2565
|
HeroInspector,
|
|
2417
2566
|
IntroInspector,
|
|
2418
2567
|
FaqInspector,
|
|
@@ -112,6 +112,87 @@ export function TextField({
|
|
|
112
112
|
);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
const CONTROL_CLASS =
|
|
116
|
+
"w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:outline-none focus:border-sea";
|
|
117
|
+
|
|
118
|
+
/** Labelled `<select>` in the inspector's house style. */
|
|
119
|
+
export function SelectField<T extends string>({
|
|
120
|
+
label,
|
|
121
|
+
value,
|
|
122
|
+
options,
|
|
123
|
+
onChange,
|
|
124
|
+
hint,
|
|
125
|
+
}: {
|
|
126
|
+
label: string;
|
|
127
|
+
value: T;
|
|
128
|
+
options: readonly { value: T; label: string }[];
|
|
129
|
+
onChange: (v: T) => void;
|
|
130
|
+
hint?: string;
|
|
131
|
+
}) {
|
|
132
|
+
return (
|
|
133
|
+
<label className="block space-y-1.5">
|
|
134
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</span>
|
|
135
|
+
<select
|
|
136
|
+
value={value}
|
|
137
|
+
onChange={(e) => onChange(e.target.value as T)}
|
|
138
|
+
className={CONTROL_CLASS}
|
|
139
|
+
>
|
|
140
|
+
{options.map((o) => (
|
|
141
|
+
<option key={o.value} value={o.value}>
|
|
142
|
+
{o.label}
|
|
143
|
+
</option>
|
|
144
|
+
))}
|
|
145
|
+
</select>
|
|
146
|
+
{hint ? <span className="block text-[0.7rem] text-ink/50">{hint}</span> : null}
|
|
147
|
+
</label>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Labelled number input with a unit suffix and hard bounds. */
|
|
152
|
+
export function NumberField({
|
|
153
|
+
label,
|
|
154
|
+
value,
|
|
155
|
+
onChange,
|
|
156
|
+
min,
|
|
157
|
+
max,
|
|
158
|
+
step = 1,
|
|
159
|
+
suffix,
|
|
160
|
+
hint,
|
|
161
|
+
}: {
|
|
162
|
+
label: string;
|
|
163
|
+
value: number;
|
|
164
|
+
onChange: (v: number) => void;
|
|
165
|
+
min?: number;
|
|
166
|
+
max?: number;
|
|
167
|
+
step?: number;
|
|
168
|
+
suffix?: string;
|
|
169
|
+
hint?: string;
|
|
170
|
+
}) {
|
|
171
|
+
return (
|
|
172
|
+
<label className="block space-y-1.5">
|
|
173
|
+
<span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">{label}</span>
|
|
174
|
+
<span className="flex items-center gap-2">
|
|
175
|
+
<input
|
|
176
|
+
type="number"
|
|
177
|
+
value={Number.isFinite(value) ? value : ""}
|
|
178
|
+
min={min}
|
|
179
|
+
max={max}
|
|
180
|
+
step={step}
|
|
181
|
+
onChange={(e) => {
|
|
182
|
+
const n = Number(e.target.value);
|
|
183
|
+
if (!Number.isFinite(n)) return;
|
|
184
|
+
const clamped = Math.min(max ?? n, Math.max(min ?? n, n));
|
|
185
|
+
onChange(clamped);
|
|
186
|
+
}}
|
|
187
|
+
className={CONTROL_CLASS}
|
|
188
|
+
/>
|
|
189
|
+
{suffix ? <span className="shrink-0 text-xs text-ink/60">{suffix}</span> : null}
|
|
190
|
+
</span>
|
|
191
|
+
{hint ? <span className="block text-[0.7rem] text-ink/50">{hint}</span> : null}
|
|
192
|
+
</label>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
115
196
|
export function InspectorSection({
|
|
116
197
|
title,
|
|
117
198
|
children,
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
CtaBannerInspector,
|
|
43
43
|
CountdownInspector,
|
|
44
44
|
MediaSplitInspector,
|
|
45
|
+
LayoutInspector,
|
|
45
46
|
type BlockPatchProps,
|
|
46
47
|
} from "./blocks";
|
|
47
48
|
|
|
@@ -722,6 +723,8 @@ export function BlockInspector({
|
|
|
722
723
|
globalSections: { key: string; name: string }[];
|
|
723
724
|
}) {
|
|
724
725
|
switch (block.type) {
|
|
726
|
+
case "layout":
|
|
727
|
+
return <LayoutInspector block={block} patch={patch} />;
|
|
725
728
|
case "hero":
|
|
726
729
|
return <HeroInspector block={block} patch={patch} />;
|
|
727
730
|
case "intro":
|
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, type ReactNode } from "react";
|
|
1
2
|
import { Loader2 } from "lucide-react";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
|
-
* Small backoffice confirmation modal (English UI) used before destructive
|
|
5
|
-
* bulk
|
|
5
|
+
* Small backoffice confirmation modal (English UI) used before destructive,
|
|
6
|
+
* bulk or publication-changing actions.
|
|
7
|
+
*
|
|
8
|
+
* It is a real dialog for assistive technology: labelled by its heading,
|
|
9
|
+
* described by its body, focus moved to the confirm button on open and
|
|
10
|
+
* restored on close, Escape cancels and Tab is trapped inside.
|
|
6
11
|
*/
|
|
7
12
|
export function ConfirmDialog({
|
|
8
13
|
title,
|
|
@@ -12,6 +17,7 @@ export function ConfirmDialog({
|
|
|
12
17
|
cancelLabel = "Cancel",
|
|
13
18
|
danger = false,
|
|
14
19
|
busy = false,
|
|
20
|
+
children,
|
|
15
21
|
onConfirm,
|
|
16
22
|
onCancel,
|
|
17
23
|
}: {
|
|
@@ -22,23 +28,76 @@ export function ConfirmDialog({
|
|
|
22
28
|
cancelLabel?: string;
|
|
23
29
|
danger?: boolean;
|
|
24
30
|
busy?: boolean;
|
|
31
|
+
/** Extra controls, e.g. the name field a duplicate is created with. */
|
|
32
|
+
children?: ReactNode;
|
|
25
33
|
onConfirm: () => void;
|
|
26
34
|
onCancel: () => void;
|
|
27
35
|
}) {
|
|
36
|
+
const id = useId();
|
|
37
|
+
const panel = useRef<HTMLDivElement>(null);
|
|
38
|
+
const confirmButton = useRef<HTMLButtonElement>(null);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const opener = document.activeElement as HTMLElement | null;
|
|
42
|
+
// Custom content owns the focus (a text field wants it); otherwise the
|
|
43
|
+
// safe default is the confirm button the dialog was opened to offer.
|
|
44
|
+
if (!children) confirmButton.current?.focus();
|
|
45
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
46
|
+
if (event.key === "Escape") {
|
|
47
|
+
event.stopPropagation();
|
|
48
|
+
onCancel();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (event.key !== "Tab" || !panel.current) return;
|
|
52
|
+
const focusable = panel.current.querySelectorAll<HTMLElement>(
|
|
53
|
+
'a[href], button:not([disabled]), input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])',
|
|
54
|
+
);
|
|
55
|
+
const first = focusable[0];
|
|
56
|
+
const last = focusable[focusable.length - 1];
|
|
57
|
+
if (!first || !last) return;
|
|
58
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
last.focus();
|
|
61
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
first.focus();
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
67
|
+
return () => {
|
|
68
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
69
|
+
opener?.focus?.();
|
|
70
|
+
};
|
|
71
|
+
// Mount-only: re-running would steal focus back mid-interaction.
|
|
72
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
28
75
|
return (
|
|
29
76
|
<div
|
|
30
77
|
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"
|
|
31
78
|
onClick={() => !busy && onCancel()}
|
|
32
79
|
>
|
|
33
80
|
<div
|
|
81
|
+
ref={panel}
|
|
82
|
+
role="dialog"
|
|
83
|
+
aria-modal="true"
|
|
84
|
+
aria-labelledby={`${id}-title`}
|
|
85
|
+
aria-describedby={description ? `${id}-description` : undefined}
|
|
34
86
|
className="w-full max-w-md border border-ink/15 bg-white"
|
|
35
87
|
onClick={(e) => e.stopPropagation()}
|
|
36
88
|
>
|
|
37
89
|
<div className="border-b border-ink/10 px-4 py-3">
|
|
38
|
-
<h2 className="text-sm font-medium text-ink">
|
|
90
|
+
<h2 id={`${id}-title`} className="text-sm font-medium text-ink">
|
|
91
|
+
{title}
|
|
92
|
+
</h2>
|
|
39
93
|
</div>
|
|
40
94
|
<div className="space-y-3 px-4 py-4">
|
|
41
|
-
{description ?
|
|
95
|
+
{description ? (
|
|
96
|
+
<p id={`${id}-description`} className="text-sm text-ink/70">
|
|
97
|
+
{description}
|
|
98
|
+
</p>
|
|
99
|
+
) : null}
|
|
100
|
+
{children}
|
|
42
101
|
{details?.length ? (
|
|
43
102
|
<ul className="max-h-40 space-y-1 overflow-auto border border-ink/10 bg-ice/30 p-2 text-[0.72rem] text-ink/70">
|
|
44
103
|
{details.map((d) => (
|
|
@@ -59,6 +118,7 @@ export function ConfirmDialog({
|
|
|
59
118
|
{cancelLabel}
|
|
60
119
|
</button>
|
|
61
120
|
<button
|
|
121
|
+
ref={confirmButton}
|
|
62
122
|
type="button"
|
|
63
123
|
disabled={busy}
|
|
64
124
|
onClick={onConfirm}
|
|
@@ -81,6 +81,19 @@ export type BackofficeHost = {
|
|
|
81
81
|
// locale domains
|
|
82
82
|
saveLocaleDomain: HostServerFn;
|
|
83
83
|
deleteLocaleDomain: HostServerFn;
|
|
84
|
+
/*
|
|
85
|
+
* Which hostname actually serves the backoffice, and why.
|
|
86
|
+
*
|
|
87
|
+
* `SITE_URL` is server information: it cannot be read from the browser,
|
|
88
|
+
* and a value baked into this registry would be a guess presented to an
|
|
89
|
+
* administrator as fact about their own install. So the answer is fetched
|
|
90
|
+
* from the server instead, and returns the package's `EffectiveAdminHost`.
|
|
91
|
+
*
|
|
92
|
+
* Optional: a host that has not registered it simply gets no statement
|
|
93
|
+
* about the backoffice hostname on the domains screen. The editor works
|
|
94
|
+
* either way — this only ever adds an explanation, never a permission.
|
|
95
|
+
*/
|
|
96
|
+
getEffectiveAdminHost?: HostServerFn;
|
|
84
97
|
/*
|
|
85
98
|
* Popup opt-in destinations, for the popup editor's picker.
|
|
86
99
|
*
|
|
@@ -179,6 +192,8 @@ export type BackofficeHost = {
|
|
|
179
192
|
saveForm: HostServerFn;
|
|
180
193
|
syncSubmissionsToSheet: HostServerFn;
|
|
181
194
|
testWebhook: HostServerFn;
|
|
195
|
+
/** Optional: verifies the Google Sheets credentials, sheet and tab. */
|
|
196
|
+
testSheetConnection?: HostServerFn;
|
|
182
197
|
listSubmissions: HostServerFn;
|
|
183
198
|
setSubmissionStatus: HostServerFn;
|
|
184
199
|
pushSubmissionsToSheet: HostServerFn;
|
|
@@ -211,6 +226,13 @@ export type BackofficeHost = {
|
|
|
211
226
|
listPagePublications: HostServerFn;
|
|
212
227
|
cancelPagePublication: HostServerFn;
|
|
213
228
|
renamePage: HostServerFn;
|
|
229
|
+
/**
|
|
230
|
+
* Optional publish/unpublish and duplicate, both confirmed in the UI.
|
|
231
|
+
* Optional so a host that has not registered them keeps working: the
|
|
232
|
+
* Pages list simply hides the two actions.
|
|
233
|
+
*/
|
|
234
|
+
setPageStatus?: HostServerFn;
|
|
235
|
+
duplicatePage?: HostServerFn;
|
|
214
236
|
/**
|
|
215
237
|
* The site's pages, listed.
|
|
216
238
|
*
|
|
@@ -226,6 +248,14 @@ export type BackofficeHost = {
|
|
|
226
248
|
/**
|
|
227
249
|
* React hook returning a resolver for a page's public URL. Implemented as a
|
|
228
250
|
* hook so a site can read its slug overrides and locale domains.
|
|
251
|
+
*
|
|
252
|
+
* `path` is site-relative. `host` is the public hostname for that locale,
|
|
253
|
+
* or `""` when the locale has no public hostname — since m7.10 the
|
|
254
|
+
* locale-to-domain table is explicit, so a locale missing from it has no
|
|
255
|
+
* public site at all. An empty `host` therefore means NO PUBLIC URL
|
|
256
|
+
* EXISTS; it is not a signal to use `path` on its own, and a host must
|
|
257
|
+
* not answer it by borrowing the current hostname or another locale's
|
|
258
|
+
* domain. Screens are expected to offer no public link in that case.
|
|
229
259
|
*/
|
|
230
260
|
usePageUrl: () => (
|
|
231
261
|
pageKey: string,
|
|
@@ -317,6 +347,7 @@ export function backofficePaths(host: BackofficeHost = backofficeHost()) {
|
|
|
317
347
|
email: `${base}/admin/email`,
|
|
318
348
|
contact: `${base}/admin/contact`,
|
|
319
349
|
tracking: `${base}/admin/tracking`,
|
|
350
|
+
domains: `${base}/admin/domains`,
|
|
320
351
|
redirects: `${base}/admin/redirects`,
|
|
321
352
|
sitemap: `${base}/admin/sitemap`,
|
|
322
353
|
navigation: `${base}/admin/navigation`,
|