@bygghammar/cms-platform 0.45.0 → 0.46.1
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 +13 -0
- package/package.json +11 -3
- package/scripts/create-cms-site.mjs +153 -0
- package/src/backoffice/DashboardView.tsx +139 -0
- package/src/backoffice/PageBuilderView.tsx +587 -0
- package/src/backoffice/ResetPasswordView.tsx +78 -0
- package/src/backoffice/SectionEditorView.tsx +211 -0
- package/src/backoffice/VersionView.tsx +31 -0
- package/src/backoffice/routes/host.ts +67 -1
- package/src/backoffice/routes/pages.tsx +246 -0
- package/src/config/define-site.ts +102 -0
- package/src/server/schema-drift.ts +108 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared-content section editor — host-agnostic.
|
|
3
|
+
*
|
|
4
|
+
* Form/JSON editor for one shared content section, with save and
|
|
5
|
+
* reset-to-code-default. The site supplies the section metadata, the recursive
|
|
6
|
+
* form component and the save call.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
10
|
+
import { ChevronLeft, Code2, FormInput, RotateCcw, Save } from "lucide-react";
|
|
11
|
+
|
|
12
|
+
/** Metadata for one editable shared section. */
|
|
13
|
+
export type EditableSectionMeta = { key: string; label: string; description?: string };
|
|
14
|
+
|
|
15
|
+
/** Everything the section editor needs from its host. */
|
|
16
|
+
export type SectionEditorDeps = {
|
|
17
|
+
sectionKey: string;
|
|
18
|
+
meta?: EditableSectionMeta;
|
|
19
|
+
/** Current value: stored override merged over the code default. */
|
|
20
|
+
value: unknown;
|
|
21
|
+
/** Code default for this section, used by "Reset to default". */
|
|
22
|
+
codeDefault: unknown;
|
|
23
|
+
save: (value: unknown) => Promise<unknown>;
|
|
24
|
+
goToList: () => void;
|
|
25
|
+
renderForm: (args: { value: unknown; onChange: (next: unknown) => void }) => ReactNode;
|
|
26
|
+
notify: {
|
|
27
|
+
success: (m: string) => void;
|
|
28
|
+
error: (m: string) => void;
|
|
29
|
+
info: (m: string) => void;
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Host-agnostic shared-content section editor. */
|
|
34
|
+
export function SectionEditorView({ deps }: { deps: SectionEditorDeps }) {
|
|
35
|
+
const { sectionKey, meta, value, notify } = deps;
|
|
36
|
+
|
|
37
|
+
const [draft, setDraft] = useState<unknown>(value);
|
|
38
|
+
const [jsonText, setJsonText] = useState("");
|
|
39
|
+
const [mode, setMode] = useState<"form" | "json">("form");
|
|
40
|
+
const [busy, setBusy] = useState(false);
|
|
41
|
+
const [dirty, setDirty] = useState(false);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
setDraft(value);
|
|
45
|
+
setJsonText(JSON.stringify(value, null, 2));
|
|
46
|
+
setDirty(false);
|
|
47
|
+
}, [value]);
|
|
48
|
+
|
|
49
|
+
if (!meta) {
|
|
50
|
+
return (
|
|
51
|
+
<div className="p-8">
|
|
52
|
+
<div className="text-ink">Unknown section.</div>
|
|
53
|
+
<button onClick={deps.goToList} className="underline text-sm text-ink/70">
|
|
54
|
+
← Back
|
|
55
|
+
</button>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const switchMode = (next: "form" | "json") => {
|
|
61
|
+
if (next === mode) return;
|
|
62
|
+
if (next === "json") {
|
|
63
|
+
setJsonText(JSON.stringify(draft, null, 2));
|
|
64
|
+
setMode("json");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
setDraft(JSON.parse(jsonText));
|
|
69
|
+
setMode("form");
|
|
70
|
+
} catch (err) {
|
|
71
|
+
notify.error("Invalid JSON: " + (err instanceof Error ? err.message : "unknown"));
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const onSave = async () => {
|
|
76
|
+
let payload: unknown = draft;
|
|
77
|
+
if (mode === "json") {
|
|
78
|
+
try {
|
|
79
|
+
payload = JSON.parse(jsonText);
|
|
80
|
+
setDraft(payload);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
notify.error("Invalid JSON: " + (err instanceof Error ? err.message : "unknown error"));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
setBusy(true);
|
|
87
|
+
try {
|
|
88
|
+
await deps.save(payload);
|
|
89
|
+
notify.success("Saved");
|
|
90
|
+
setDirty(false);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
notify.error(err instanceof Error ? err.message : "Could not save");
|
|
93
|
+
} finally {
|
|
94
|
+
setBusy(false);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const onReset = () => {
|
|
99
|
+
setDraft(deps.codeDefault);
|
|
100
|
+
setJsonText(JSON.stringify(deps.codeDefault, null, 2));
|
|
101
|
+
setDirty(true);
|
|
102
|
+
notify.info("Reset to default — remember to save");
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className="p-8 md:p-12 w-full">
|
|
107
|
+
<button
|
|
108
|
+
onClick={deps.goToList}
|
|
109
|
+
className="inline-flex items-center gap-2 text-xs uppercase tracking-[0.22em] text-ink/60 hover:text-ink"
|
|
110
|
+
>
|
|
111
|
+
<ChevronLeft className="size-4" />
|
|
112
|
+
All sections
|
|
113
|
+
</button>
|
|
114
|
+
|
|
115
|
+
<div className="mt-6 text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">
|
|
116
|
+
Section · {sectionKey}
|
|
117
|
+
</div>
|
|
118
|
+
<h1 className="mt-2 font-serif text-3xl md:text-4xl text-ink">{meta.label}</h1>
|
|
119
|
+
{meta.description && <p className="mt-3 text-ink/65 max-w-xl">{meta.description}</p>}
|
|
120
|
+
|
|
121
|
+
<div className="mt-6 inline-flex border border-ink/15 bg-white">
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
onClick={() => switchMode("form")}
|
|
125
|
+
className={`inline-flex items-center gap-2 px-4 py-2 text-[0.7rem] uppercase tracking-[0.2em] ${
|
|
126
|
+
mode === "form" ? "bg-ink text-paper" : "text-ink/70 hover:text-ink"
|
|
127
|
+
}`}
|
|
128
|
+
>
|
|
129
|
+
<FormInput className="size-3.5" /> Form
|
|
130
|
+
</button>
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
onClick={() => switchMode("json")}
|
|
134
|
+
className={`inline-flex items-center gap-2 px-4 py-2 text-[0.7rem] uppercase tracking-[0.2em] border-l border-ink/15 ${
|
|
135
|
+
mode === "json" ? "bg-ink text-paper" : "text-ink/70 hover:text-ink"
|
|
136
|
+
}`}
|
|
137
|
+
>
|
|
138
|
+
<Code2 className="size-3.5" /> JSON
|
|
139
|
+
</button>
|
|
140
|
+
{dirty && (
|
|
141
|
+
<span className="px-3 py-2 text-[0.65rem] uppercase tracking-[0.2em] text-sea self-center">
|
|
142
|
+
unsaved
|
|
143
|
+
</span>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<div className="mt-6">
|
|
148
|
+
{mode === "form" ? (
|
|
149
|
+
deps.renderForm({
|
|
150
|
+
value: draft,
|
|
151
|
+
onChange: (next) => {
|
|
152
|
+
setDraft(next);
|
|
153
|
+
setDirty(true);
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
) : (
|
|
157
|
+
<div className="border border-ink/10 bg-white">
|
|
158
|
+
<textarea
|
|
159
|
+
value={jsonText}
|
|
160
|
+
onChange={(e) => {
|
|
161
|
+
setJsonText(e.target.value);
|
|
162
|
+
setDirty(true);
|
|
163
|
+
}}
|
|
164
|
+
spellCheck={false}
|
|
165
|
+
className="w-full font-mono text-xs p-4 min-h-[520px] outline-none resize-y bg-white text-ink"
|
|
166
|
+
/>
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<div className="mt-6 flex flex-wrap items-center gap-3">
|
|
172
|
+
<button
|
|
173
|
+
onClick={onSave}
|
|
174
|
+
disabled={busy || !dirty}
|
|
175
|
+
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"
|
|
176
|
+
>
|
|
177
|
+
<Save className="size-4" />
|
|
178
|
+
{busy ? "Saving…" : "Save"}
|
|
179
|
+
</button>
|
|
180
|
+
<button
|
|
181
|
+
onClick={onReset}
|
|
182
|
+
className="inline-flex items-center gap-2 border border-ink/20 px-6 py-3 text-xs uppercase tracking-[0.22em] hover:border-ink"
|
|
183
|
+
>
|
|
184
|
+
<RotateCcw className="size-4" />
|
|
185
|
+
Reset to default
|
|
186
|
+
</button>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<p className="mt-6 text-xs text-ink/50 max-w-xl">
|
|
190
|
+
The form editor renders every field automatically — text fields expose per-language tabs,
|
|
191
|
+
images have an alt-text editor per language, and lists can be reordered, added to, or removed
|
|
192
|
+
from. Switch to JSON only for bulk edits.
|
|
193
|
+
</p>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isObj(v: unknown): v is Record<string, unknown> {
|
|
199
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Recursively adds keys present in `def` but missing in `val`. */
|
|
203
|
+
export function withSectionDefaults(val: unknown, def: unknown): unknown {
|
|
204
|
+
if (!isObj(val) || !isObj(def)) return val;
|
|
205
|
+
const out: Record<string, unknown> = { ...val };
|
|
206
|
+
for (const [k, dv] of Object.entries(def)) {
|
|
207
|
+
if (!(k in out)) out[k] = dv;
|
|
208
|
+
else out[k] = withSectionDefaults(out[k], dv);
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Package } from "lucide-react";
|
|
2
2
|
import { useEffect, useState } from "react";
|
|
3
3
|
|
|
4
|
+
import { SCHEMA_BASELINE, SCHEMA_MIGRATIONS, type SchemaDrift } from "../server/schema-drift";
|
|
5
|
+
|
|
4
6
|
/** Build metadata injected at compile time by the host. */
|
|
5
7
|
export type PlatformBuildInfo = {
|
|
6
8
|
appVersion?: string | null;
|
|
@@ -23,6 +25,8 @@ export type VersionDeps = {
|
|
|
23
25
|
loading?: boolean;
|
|
24
26
|
/** Package name shown in the release history copy. */
|
|
25
27
|
packageName?: string;
|
|
28
|
+
/** Schema comparison against the packaged baseline, when the host supplies it. */
|
|
29
|
+
drift?: SchemaDrift;
|
|
26
30
|
};
|
|
27
31
|
|
|
28
32
|
function fmt(value: string | null | undefined) {
|
|
@@ -74,6 +78,13 @@ export function VersionView({ deps }: { deps: VersionDeps }) {
|
|
|
74
78
|
{ label: "Platform package", value: build?.platformVersion ?? "—" },
|
|
75
79
|
{ label: "Declared range", value: build?.platformRange ?? "—" },
|
|
76
80
|
{ label: "Current time", value: now ? formatUtc(now) : "—" },
|
|
81
|
+
{ label: "Schema baseline", value: SCHEMA_BASELINE || "—" },
|
|
82
|
+
{
|
|
83
|
+
label: "Canonical migrations",
|
|
84
|
+
value: deps.drift
|
|
85
|
+
? `${deps.drift.appliedCount} applied / ${deps.drift.expectedCount} expected`
|
|
86
|
+
: String(SCHEMA_MIGRATIONS.length),
|
|
87
|
+
},
|
|
77
88
|
].map((row) => (
|
|
78
89
|
<div key={row.label} className="border border-ink/10 bg-white p-5">
|
|
79
90
|
<dt className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/50">
|
|
@@ -84,6 +95,26 @@ export function VersionView({ deps }: { deps: VersionDeps }) {
|
|
|
84
95
|
))}
|
|
85
96
|
</dl>
|
|
86
97
|
|
|
98
|
+
{deps.drift && !deps.drift.inSync && (
|
|
99
|
+
<div className="mt-8 border border-amber-300 bg-amber-50 p-5 text-sm">
|
|
100
|
+
<div className="font-serif text-base text-ink">Database schema drift</div>
|
|
101
|
+
<p className="mt-1 text-ink/70">
|
|
102
|
+
This database does not match the schema baseline shipped with the platform package.
|
|
103
|
+
</p>
|
|
104
|
+
{deps.drift.missing.length > 0 && (
|
|
105
|
+
<p className="mt-2 text-ink/80">
|
|
106
|
+
Missing {deps.drift.missing.length} canonical migration(s), starting with{" "}
|
|
107
|
+
<span className="font-mono">{deps.drift.missing[0]}</span>.
|
|
108
|
+
</p>
|
|
109
|
+
)}
|
|
110
|
+
{deps.drift.extra.length > 0 && (
|
|
111
|
+
<p className="mt-2 text-ink/80">
|
|
112
|
+
{deps.drift.extra.length} applied migration(s) are unknown to this package version.
|
|
113
|
+
</p>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
|
|
87
118
|
<h3 className="mt-12 font-serif text-lg text-ink">Latest platform releases</h3>
|
|
88
119
|
<p className="mt-1 text-sm text-ink/60">
|
|
89
120
|
The three most recent published versions of {packageName} with their publish dates.
|
|
@@ -179,6 +179,70 @@ export type BackofficeHost = {
|
|
|
179
179
|
ActivityLog: any;
|
|
180
180
|
};
|
|
181
181
|
};
|
|
182
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
183
|
+
/** Page builder adapters: server calls, public URLs and editor slots. */
|
|
184
|
+
pages?: {
|
|
185
|
+
fns: {
|
|
186
|
+
getPageForEdit: HostServerFn;
|
|
187
|
+
getPublishedPage: HostServerFn;
|
|
188
|
+
savePageVersion: HostServerFn;
|
|
189
|
+
listPageVersions: HostServerFn;
|
|
190
|
+
restorePageVersion: HostServerFn;
|
|
191
|
+
listGlobalSections: HostServerFn;
|
|
192
|
+
schedulePageVersion: HostServerFn;
|
|
193
|
+
listPagePublications: HostServerFn;
|
|
194
|
+
cancelPagePublication: HostServerFn;
|
|
195
|
+
renamePage: HostServerFn;
|
|
196
|
+
};
|
|
197
|
+
/** Human labels for the locale switch, keyed by locale. */
|
|
198
|
+
localeLabels?: Record<string, string>;
|
|
199
|
+
/**
|
|
200
|
+
* React hook returning a resolver for a page's public URL. Implemented as a
|
|
201
|
+
* hook so a site can read its slug overrides and locale domains.
|
|
202
|
+
*/
|
|
203
|
+
usePageUrl: () => (pageKey: string, locale: string) => { path: string; host: string };
|
|
204
|
+
/** Persists unsaved shared-content drafts; returns the keys it saved. */
|
|
205
|
+
flushSharedDrafts?: () => Promise<string[]>;
|
|
206
|
+
/** Renders the block canvas for the builder. */
|
|
207
|
+
renderEditor: (args: {
|
|
208
|
+
pageKey: string;
|
|
209
|
+
/** Blocks as last loaded from the server (the editor's baseline). */
|
|
210
|
+
initialBlocks: any[];
|
|
211
|
+
blocks: any[];
|
|
212
|
+
setBlocks: (next: any[]) => void;
|
|
213
|
+
globals: Record<string, any[]>;
|
|
214
|
+
globalSections: { key: string; name: string; kind: "block" | "section"; blocks: any[] }[];
|
|
215
|
+
locale: string;
|
|
216
|
+
setLocale: (next: string) => void;
|
|
217
|
+
setDirty: (next: boolean) => void;
|
|
218
|
+
}) => ReactNode;
|
|
219
|
+
/** Renders the page SEO panel below the canvas. */
|
|
220
|
+
renderSeoPanel?: (pageKey: string) => ReactNode;
|
|
221
|
+
/** Opens the live draft preview in a new tab. */
|
|
222
|
+
openPreview?: (pageKey: string, locale: string) => void;
|
|
223
|
+
};
|
|
224
|
+
/** Shared-content section metadata for the section editor screen. */
|
|
225
|
+
sections?: {
|
|
226
|
+
editable: { key: string; label: string; description?: string }[];
|
|
227
|
+
};
|
|
228
|
+
/** Dashboard shortcut cards and the optional translation sweep. */
|
|
229
|
+
dashboard?: {
|
|
230
|
+
cards: readonly {
|
|
231
|
+
to: string;
|
|
232
|
+
title: string;
|
|
233
|
+
body: string;
|
|
234
|
+
icon: (props: { className?: string }) => ReactNode;
|
|
235
|
+
}[];
|
|
236
|
+
translateTarget?: string;
|
|
237
|
+
translate?: HostServerFn;
|
|
238
|
+
};
|
|
239
|
+
/** Browser auth adapters used by the password-reset screen. */
|
|
240
|
+
authAdapters?: {
|
|
241
|
+
/** Subscribes to the recovery session; returns an unsubscribe function. */
|
|
242
|
+
onRecoverySession: (ready: () => void) => () => void;
|
|
243
|
+
updatePassword: (password: string) => Promise<void>;
|
|
244
|
+
};
|
|
245
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
182
246
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
183
247
|
};
|
|
184
248
|
|
|
@@ -226,11 +290,13 @@ export function backofficePaths(host: BackofficeHost = backofficeHost()) {
|
|
|
226
290
|
media: `${base}/media`,
|
|
227
291
|
formSubmissions: `${base}/forms/submissions`,
|
|
228
292
|
formDeliveries: `${base}/forms/deliveries`,
|
|
293
|
+
pagesBuilder: `${base}/pages-builder`,
|
|
294
|
+
resetPassword: `${base}/reset-password`,
|
|
229
295
|
};
|
|
230
296
|
}
|
|
231
297
|
|
|
232
298
|
/** Reads a required optional host group, failing loudly when it is missing. */
|
|
233
|
-
export function requireHostGroup<K extends "media" | "forms">(
|
|
299
|
+
export function requireHostGroup<K extends "media" | "forms" | "pages" | "sections" | "dashboard" | "authAdapters">(
|
|
234
300
|
key: K,
|
|
235
301
|
): NonNullable<BackofficeHost[K]> {
|
|
236
302
|
const group = backofficeHost()[key];
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned page-builder, section-editor, dashboard and reset-password routes.
|
|
3
|
+
*
|
|
4
|
+
* Each component reads its adapters from the backoffice host registry, wires the
|
|
5
|
+
* queries and renders the shared view, so a site route file stays a two-line
|
|
6
|
+
* `createFileRoute(...)({ component: XRoute })`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
10
|
+
|
|
11
|
+
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
|
12
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
13
|
+
import { useServerFn } from "@tanstack/react-start";
|
|
14
|
+
import { useMemo } from "react";
|
|
15
|
+
import { toast } from "sonner";
|
|
16
|
+
|
|
17
|
+
import { PageBuilderView } from "../PageBuilderView";
|
|
18
|
+
import { SectionEditorView } from "../SectionEditorView";
|
|
19
|
+
import { DashboardView, type DashboardCard } from "../DashboardView";
|
|
20
|
+
import { ResetPasswordView } from "../ResetPasswordView";
|
|
21
|
+
import { backofficeHost, backofficePaths, requireHostGroup } from "./host";
|
|
22
|
+
|
|
23
|
+
const notify = {
|
|
24
|
+
success: (m: string) => toast.success(m),
|
|
25
|
+
error: (m: string) => toast.error(m),
|
|
26
|
+
info: (m: string) => toast.info(m),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Page builder screen for `/<backoffice>/pages-builder/$key`. */
|
|
30
|
+
export function PageBuilderRoute() {
|
|
31
|
+
const host = backofficeHost();
|
|
32
|
+
const pages = requireHostGroup("pages");
|
|
33
|
+
const paths = backofficePaths(host);
|
|
34
|
+
const navigate = useNavigate();
|
|
35
|
+
const queryClient = useQueryClient();
|
|
36
|
+
const params = useParams({ strict: false }) as { key?: string };
|
|
37
|
+
const key = params.key ?? "";
|
|
38
|
+
|
|
39
|
+
const fetchPage = useServerFn(pages.fns.getPageForEdit);
|
|
40
|
+
const fetchPublished = useServerFn(pages.fns.getPublishedPage);
|
|
41
|
+
const listGlobals = useServerFn(pages.fns.listGlobalSections);
|
|
42
|
+
const save = useServerFn(pages.fns.savePageVersion);
|
|
43
|
+
const listVers = useServerFn(pages.fns.listPageVersions);
|
|
44
|
+
const restore = useServerFn(pages.fns.restorePageVersion);
|
|
45
|
+
const schedule = useServerFn(pages.fns.schedulePageVersion);
|
|
46
|
+
const listPubs = useServerFn(pages.fns.listPagePublications);
|
|
47
|
+
const cancelPub = useServerFn(pages.fns.cancelPagePublication);
|
|
48
|
+
const rename = useServerFn(pages.fns.renamePage);
|
|
49
|
+
|
|
50
|
+
const pageQ = useQuery({
|
|
51
|
+
queryKey: ["cms", "page-edit", key],
|
|
52
|
+
queryFn: () => fetchPage({ data: { key } }),
|
|
53
|
+
});
|
|
54
|
+
const publishedQ = useQuery({
|
|
55
|
+
queryKey: ["cms", "page-published-globals", key],
|
|
56
|
+
queryFn: () => fetchPublished({ data: { key } }),
|
|
57
|
+
});
|
|
58
|
+
const globalsQ = useQuery({ queryKey: ["cms", "globals-list"], queryFn: () => listGlobals() });
|
|
59
|
+
|
|
60
|
+
const pageId = pageQ.data?.page?.id as string | undefined;
|
|
61
|
+
const versionsQ = useQuery({
|
|
62
|
+
queryKey: ["cms", "page-versions", pageId],
|
|
63
|
+
queryFn: () => listVers({ data: { pageId } }),
|
|
64
|
+
enabled: !!pageId,
|
|
65
|
+
});
|
|
66
|
+
const pubsQ = useQuery({
|
|
67
|
+
queryKey: ["cms", "page-pubs", pageId],
|
|
68
|
+
queryFn: () => listPubs({ data: { pageId } }),
|
|
69
|
+
enabled: !!pageId,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const resolveUrl = pages.usePageUrl();
|
|
73
|
+
|
|
74
|
+
const globalSections = useMemo(
|
|
75
|
+
() =>
|
|
76
|
+
(globalsQ.data ?? []).map((g: any) => ({
|
|
77
|
+
key: g.key as string,
|
|
78
|
+
name: g.name as string,
|
|
79
|
+
kind: (g.kind === "block" ? "block" : "section") as "block" | "section",
|
|
80
|
+
blocks: (g.blocks ?? []) as any[],
|
|
81
|
+
})),
|
|
82
|
+
[globalsQ.data],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const globals = useMemo(() => {
|
|
86
|
+
// Latest saved global content wins over the published page snapshot.
|
|
87
|
+
const map: Record<string, any[]> = { ...((publishedQ.data as any)?.globals ?? {}) };
|
|
88
|
+
for (const g of globalsQ.data ?? []) map[g.key] = (g.blocks ?? []) as any[];
|
|
89
|
+
return map;
|
|
90
|
+
}, [publishedQ.data, globalsQ.data]);
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<PageBuilderView<any>
|
|
94
|
+
deps={{
|
|
95
|
+
pageKey: key,
|
|
96
|
+
page: pageQ.data?.page as any,
|
|
97
|
+
blocksFromServer: pageQ.data?.blocks as any[] | undefined,
|
|
98
|
+
isLoading: pageQ.isLoading,
|
|
99
|
+
error: (pageQ.error as Error) ?? null,
|
|
100
|
+
globalSections,
|
|
101
|
+
globals,
|
|
102
|
+
locales: host.locales,
|
|
103
|
+
localeLabels: pages.localeLabels ?? {},
|
|
104
|
+
defaultLocale: host.siteConfig.defaultLocale,
|
|
105
|
+
publicPath: (locale) => resolveUrl(key, locale).path,
|
|
106
|
+
publicHost: (locale) => resolveUrl(key, locale).host,
|
|
107
|
+
versions: versionsQ.data as any,
|
|
108
|
+
publications: pubsQ.data as any,
|
|
109
|
+
save: async ({ pageId: id, blocks, publish, note }) => {
|
|
110
|
+
const res = await save({ data: { pageId: id, blocks, publish, note } });
|
|
111
|
+
return res as { versionId: string };
|
|
112
|
+
},
|
|
113
|
+
flushSharedDrafts: pages.flushSharedDrafts
|
|
114
|
+
? async () => {
|
|
115
|
+
const keys = await pages.flushSharedDrafts!();
|
|
116
|
+
if (keys.length)
|
|
117
|
+
await queryClient.invalidateQueries({ queryKey: host.content.overridesQueryKey });
|
|
118
|
+
return keys;
|
|
119
|
+
}
|
|
120
|
+
: undefined,
|
|
121
|
+
schedule: (input) => schedule({ data: input }),
|
|
122
|
+
cancelPublication: (publicationId) => cancelPub({ data: { publicationId } }),
|
|
123
|
+
restoreVersion: (versionId) => restore({ data: { versionId } }),
|
|
124
|
+
rename: (input) => rename({ data: input }),
|
|
125
|
+
refetchPage: () => void pageQ.refetch(),
|
|
126
|
+
refetchVersions: () => void versionsQ.refetch(),
|
|
127
|
+
refetchPublications: () => void pubsQ.refetch(),
|
|
128
|
+
goToPages: () => navigate({ to: paths.pagesBuilder as any }),
|
|
129
|
+
openPreview: (locale) =>
|
|
130
|
+
pages.openPreview
|
|
131
|
+
? pages.openPreview(key, locale)
|
|
132
|
+
: window.open(`/preview/${key}?locale=${locale}`, "_blank"),
|
|
133
|
+
renderEditor: ({ blocks, setBlocks, locale, setLocale, setDirty }) =>
|
|
134
|
+
pages.renderEditor({
|
|
135
|
+
pageKey: key,
|
|
136
|
+
initialBlocks: (pageQ.data?.blocks as any[]) ?? blocks,
|
|
137
|
+
blocks,
|
|
138
|
+
setBlocks,
|
|
139
|
+
globals,
|
|
140
|
+
globalSections,
|
|
141
|
+
locale,
|
|
142
|
+
setLocale,
|
|
143
|
+
setDirty,
|
|
144
|
+
}),
|
|
145
|
+
seoPanel: pages.renderSeoPanel?.(key),
|
|
146
|
+
footer: (
|
|
147
|
+
<>
|
|
148
|
+
Prefer to edit content text/images? Use{" "}
|
|
149
|
+
<Link to={paths.sections as any} className="text-sea hover:underline">
|
|
150
|
+
Sections
|
|
151
|
+
</Link>
|
|
152
|
+
.
|
|
153
|
+
</>
|
|
154
|
+
),
|
|
155
|
+
notify,
|
|
156
|
+
}}
|
|
157
|
+
/>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Shared-content section editor for `/<backoffice>/sections/$key`. */
|
|
162
|
+
export function SectionEditorRoute() {
|
|
163
|
+
const host = backofficeHost();
|
|
164
|
+
const sections = requireHostGroup("sections");
|
|
165
|
+
const paths = backofficePaths(host);
|
|
166
|
+
const navigate = useNavigate();
|
|
167
|
+
const queryClient = useQueryClient();
|
|
168
|
+
const params = useParams({ strict: false }) as { key?: string };
|
|
169
|
+
const sectionKey = params.key ?? "";
|
|
170
|
+
|
|
171
|
+
const overrides = host.content.useOverrides();
|
|
172
|
+
const site = host.content.useSite();
|
|
173
|
+
const save = useServerFn(host.fns.saveContentSection);
|
|
174
|
+
|
|
175
|
+
const meta = sections.editable.find((s) => s.key === sectionKey);
|
|
176
|
+
const codeDefault = host.content.defaultForSection(sectionKey) ?? site[sectionKey];
|
|
177
|
+
|
|
178
|
+
const value = useMemo(() => {
|
|
179
|
+
const override = overrides?.[sectionKey];
|
|
180
|
+
// Older overrides predate newer fields, so merge the defaults back in.
|
|
181
|
+
return override ? host.content.withSectionDefaults(override, codeDefault) : site[sectionKey];
|
|
182
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
183
|
+
}, [overrides, site, sectionKey]);
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<SectionEditorView
|
|
187
|
+
deps={{
|
|
188
|
+
sectionKey,
|
|
189
|
+
meta,
|
|
190
|
+
value,
|
|
191
|
+
codeDefault,
|
|
192
|
+
save: async (next) => {
|
|
193
|
+
await save({ data: { key: sectionKey, data: next } });
|
|
194
|
+
await queryClient.invalidateQueries({ queryKey: host.content.overridesQueryKey });
|
|
195
|
+
},
|
|
196
|
+
goToList: () => navigate({ to: paths.sections as any }),
|
|
197
|
+
renderForm: ({ value: v, onChange }) => host.components.SectionForm({ value: v, onChange }),
|
|
198
|
+
notify,
|
|
199
|
+
}}
|
|
200
|
+
/>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Backoffice dashboard for `/<backoffice>/`. */
|
|
205
|
+
export function DashboardRoute() {
|
|
206
|
+
const dashboard = requireHostGroup("dashboard");
|
|
207
|
+
const translateFn = dashboard.translate;
|
|
208
|
+
const runTranslate = useServerFn(translateFn ?? (async () => ({})));
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<DashboardView
|
|
212
|
+
deps={{
|
|
213
|
+
cards: dashboard.cards as unknown as readonly DashboardCard[],
|
|
214
|
+
renderLink: ({ to, className, children }) => (
|
|
215
|
+
<Link to={to as any} className={className}>
|
|
216
|
+
{children}
|
|
217
|
+
</Link>
|
|
218
|
+
),
|
|
219
|
+
translateTarget: dashboard.translateTarget,
|
|
220
|
+
translate: translateFn
|
|
221
|
+
? async ({ target, mode }) => (await runTranslate({ data: { target, mode } })) as any
|
|
222
|
+
: undefined,
|
|
223
|
+
notify,
|
|
224
|
+
}}
|
|
225
|
+
/>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Password-reset screen for `/<backoffice>/reset-password`. */
|
|
230
|
+
export function ResetPasswordRoute() {
|
|
231
|
+
const host = backofficeHost();
|
|
232
|
+
const auth = requireHostGroup("authAdapters");
|
|
233
|
+
const paths = backofficePaths(host);
|
|
234
|
+
const navigate = useNavigate();
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<ResetPasswordView
|
|
238
|
+
deps={{
|
|
239
|
+
onRecoverySession: auth.onRecoverySession,
|
|
240
|
+
updatePassword: auth.updatePassword,
|
|
241
|
+
onDone: () => navigate({ to: paths.base as any }),
|
|
242
|
+
notify,
|
|
243
|
+
}}
|
|
244
|
+
/>
|
|
245
|
+
);
|
|
246
|
+
}
|