@bygghammar/cms-platform 0.40.0 → 0.42.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 +16 -0
- package/package.json +4 -2
- package/src/backoffice/routes/host.ts +156 -0
- package/src/backoffice/routes/index.tsx +556 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.42.0
|
|
4
|
+
|
|
5
|
+
- Backoffice host registry gains email-settings, locale-domain, shared-content,
|
|
6
|
+
editor-component and backup adapters plus site placeholders.
|
|
7
|
+
- New package-owned routes: `AdminLayoutRoute`, `AdminOverviewRoute` (cards
|
|
8
|
+
driven by `SiteConfig.features` and superadmin role), `EmailSettingsRoute`,
|
|
9
|
+
`NavigationRoute`, `DomainsRoute`, `ReviewsRoute` and `BackupRoute`.
|
|
10
|
+
- `backofficePaths()` now derives every admin path from `backofficePath`.
|
|
11
|
+
|
|
12
|
+
## 0.41.0
|
|
13
|
+
|
|
14
|
+
- New `backoffice/routes`: a host registry (`registerBackofficeHost`) plus package-owned
|
|
15
|
+
route components for audit, email logs, log index, roles, users, version, site
|
|
16
|
+
settings, redirects, tracking, contact details and form settings. A site route
|
|
17
|
+
file is now just `createFileRoute(...)({ component: XRoute })`.
|
|
18
|
+
|
|
3
19
|
## 0.40.0
|
|
4
20
|
|
|
5
21
|
- New `backoffice/shell`: shared `BackofficeShell` (auth gate, no-access screen,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.0",
|
|
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": false,
|
|
@@ -155,7 +155,9 @@
|
|
|
155
155
|
"./server/forms-delivery-core": "./src/server/forms-delivery-core.ts",
|
|
156
156
|
"./server/forms-submissions-core": "./src/server/forms-submissions-core.ts",
|
|
157
157
|
"./server/backup-core": "./src/server/backup-core.ts",
|
|
158
|
-
"./backoffice/shell": "./src/backoffice/shell/index.ts"
|
|
158
|
+
"./backoffice/shell": "./src/backoffice/shell/index.ts",
|
|
159
|
+
"./backoffice/routes": "./src/backoffice/routes/index.tsx",
|
|
160
|
+
"./backoffice/routes/host": "./src/backoffice/routes/host.ts"
|
|
159
161
|
},
|
|
160
162
|
"dependencies": {
|
|
161
163
|
"fflate": "^0.8.3"
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backoffice host registry.
|
|
3
|
+
*
|
|
4
|
+
* A site registers its server functions and site-level facts once; the package
|
|
5
|
+
* then owns the backoffice route components themselves, so a site's route file
|
|
6
|
+
* is a two-line `createFileRoute(...)({ component: XRoute })`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
|
|
11
|
+
import type { SiteConfig } from "../../config/site-config";
|
|
12
|
+
|
|
13
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
14
|
+
/** Media field component as the reviews editor consumes it. */
|
|
15
|
+
export type MediaFieldComponent = (props: any) => ReactNode;
|
|
16
|
+
/** Review image uploader component as the reviews editor consumes it. */
|
|
17
|
+
export type ReviewImageUploadComponent = (props: any) => ReactNode;
|
|
18
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
19
|
+
|
|
20
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
21
|
+
/** A TanStack server function as seen from the host (called via `useServerFn`). */
|
|
22
|
+
export type HostServerFn = (...args: any[]) => Promise<any>;
|
|
23
|
+
/* eslint-enable @typescript-eslint/no-explicit-any */
|
|
24
|
+
|
|
25
|
+
/** Everything the package needs from a host to render the backoffice routes. */
|
|
26
|
+
export type BackofficeHost = {
|
|
27
|
+
/** Site configuration used for defaults on the settings screen. */
|
|
28
|
+
siteConfig: SiteConfig;
|
|
29
|
+
/** Locales the site publishes, used by tracking/domain screens. */
|
|
30
|
+
locales: readonly string[];
|
|
31
|
+
/** Role identifiers this platform build supports. */
|
|
32
|
+
allRoles: string[];
|
|
33
|
+
/** Company details used as code defaults on the contact screen. */
|
|
34
|
+
company: Record<string, string>;
|
|
35
|
+
/** Locale used to preview form copy in the backoffice. */
|
|
36
|
+
previewLocale?: string;
|
|
37
|
+
/** Server functions, grouped per module. */
|
|
38
|
+
fns: {
|
|
39
|
+
// admin
|
|
40
|
+
listAuditLogs: HostServerFn;
|
|
41
|
+
listEmailLogs: HostServerFn;
|
|
42
|
+
listUsers: HostServerFn;
|
|
43
|
+
getMyRoles: HostServerFn;
|
|
44
|
+
createUser: HostServerFn;
|
|
45
|
+
deleteUser: HostServerFn;
|
|
46
|
+
setUserPassword: HostServerFn;
|
|
47
|
+
sendPasswordResetEmail: HostServerFn;
|
|
48
|
+
setUserRoles: HostServerFn;
|
|
49
|
+
getSystemVersion: HostServerFn;
|
|
50
|
+
// site settings
|
|
51
|
+
getSiteSettings: HostServerFn;
|
|
52
|
+
saveSiteSettings: HostServerFn;
|
|
53
|
+
clearSiteSetting: HostServerFn;
|
|
54
|
+
// redirects
|
|
55
|
+
listRedirects: HostServerFn;
|
|
56
|
+
saveRedirect: HostServerFn;
|
|
57
|
+
deleteRedirect: HostServerFn;
|
|
58
|
+
// tracking + domains
|
|
59
|
+
listInjections: HostServerFn;
|
|
60
|
+
saveInjection: HostServerFn;
|
|
61
|
+
deleteInjection: HostServerFn;
|
|
62
|
+
getLocaleDomains: HostServerFn;
|
|
63
|
+
// shared content
|
|
64
|
+
getContentOverrides: HostServerFn;
|
|
65
|
+
saveContentSection: HostServerFn;
|
|
66
|
+
// forms
|
|
67
|
+
getFormDefaults: HostServerFn;
|
|
68
|
+
saveFormDefaults: HostServerFn;
|
|
69
|
+
purgeOldSubmissions: HostServerFn;
|
|
70
|
+
// email settings
|
|
71
|
+
getEmailSettings: HostServerFn;
|
|
72
|
+
saveEmailSettings: HostServerFn;
|
|
73
|
+
listEmailProviders: HostServerFn;
|
|
74
|
+
upsertEmailProvider: HostServerFn;
|
|
75
|
+
deleteEmailProvider: HostServerFn;
|
|
76
|
+
sendTestEmail: HostServerFn;
|
|
77
|
+
// locale domains
|
|
78
|
+
saveLocaleDomain: HostServerFn;
|
|
79
|
+
deleteLocaleDomain: HostServerFn;
|
|
80
|
+
};
|
|
81
|
+
/** Site-specific example values shown as input placeholders. */
|
|
82
|
+
placeholders?: {
|
|
83
|
+
fromEmail?: string;
|
|
84
|
+
notifyTo?: string;
|
|
85
|
+
domain?: string;
|
|
86
|
+
};
|
|
87
|
+
/** Shared-content adapters: resolved site content plus its cache key. */
|
|
88
|
+
content: {
|
|
89
|
+
/** React hook returning the resolved (defaults + overrides) site content. */
|
|
90
|
+
useSite: () => Record<string, unknown>;
|
|
91
|
+
/** React hook returning the stored overrides, or undefined while loading. */
|
|
92
|
+
useOverrides: () => Record<string, unknown> | undefined;
|
|
93
|
+
/** Query key of the overrides query, so routes can invalidate it. */
|
|
94
|
+
overridesQueryKey: readonly unknown[];
|
|
95
|
+
/** Code defaults for the whole site content tree. */
|
|
96
|
+
defaults: Record<string, unknown>;
|
|
97
|
+
/** Code default for a single shared section. */
|
|
98
|
+
defaultForSection: (key: string) => unknown;
|
|
99
|
+
};
|
|
100
|
+
/** Editor components a site wires with its own locales, media and forms. */
|
|
101
|
+
components: {
|
|
102
|
+
/** Recursive shared-content editor. */
|
|
103
|
+
SectionForm: (props: { value: unknown; onChange: (next: unknown) => void }) => ReactNode;
|
|
104
|
+
/** Single media field used by the reviews editor. */
|
|
105
|
+
MediaField: MediaFieldComponent;
|
|
106
|
+
/** Review portrait uploader. */
|
|
107
|
+
ReviewImageUpload: ReviewImageUploadComponent;
|
|
108
|
+
};
|
|
109
|
+
/** Backup screen adapters — auth headers plus browser media archiving. */
|
|
110
|
+
backup?: {
|
|
111
|
+
authHeaders: () => Promise<Record<string, string>>;
|
|
112
|
+
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
|
|
113
|
+
buildBrowserMediaArchive: (args: any) => Promise<any>;
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
let current: BackofficeHost | null = null;
|
|
118
|
+
|
|
119
|
+
/** Registers the host adapters. Call once, from a module the routes import. */
|
|
120
|
+
export function registerBackofficeHost(host: BackofficeHost): BackofficeHost {
|
|
121
|
+
current = host;
|
|
122
|
+
return host;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Reads the registered host, failing loudly when a site forgot to register. */
|
|
126
|
+
export function backofficeHost(): BackofficeHost {
|
|
127
|
+
if (!current) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
"Backoffice host not registered — call registerBackofficeHost() before rendering backoffice routes.",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
return current;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Absolute backoffice paths derived from the site's configured base path. */
|
|
136
|
+
export function backofficePaths(host: BackofficeHost = backofficeHost()) {
|
|
137
|
+
const base = host.siteConfig.backofficePath;
|
|
138
|
+
return {
|
|
139
|
+
base,
|
|
140
|
+
admin: `${base}/admin`,
|
|
141
|
+
audit: `${base}/admin/audit`,
|
|
142
|
+
emailLogs: `${base}/admin/email-logs`,
|
|
143
|
+
users: `${base}/admin/users`,
|
|
144
|
+
forms: `${base}/forms`,
|
|
145
|
+
settings: `${base}/admin/settings`,
|
|
146
|
+
roles: `${base}/admin/roles`,
|
|
147
|
+
logs: `${base}/admin/logs`,
|
|
148
|
+
email: `${base}/admin/email`,
|
|
149
|
+
contact: `${base}/admin/contact`,
|
|
150
|
+
tracking: `${base}/admin/tracking`,
|
|
151
|
+
redirects: `${base}/admin/redirects`,
|
|
152
|
+
navigation: `${base}/admin/navigation`,
|
|
153
|
+
version: `${base}/admin/version`,
|
|
154
|
+
backup: `${base}/admin/backup`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned backoffice route components.
|
|
3
|
+
*
|
|
4
|
+
* Each component is a complete screen: it reads the host adapters from the
|
|
5
|
+
* registry, wires queries/mutations and renders the shared view. A site route
|
|
6
|
+
* file only maps a URL to one of these.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Link, Outlet, useNavigate, useRouterState } from "@tanstack/react-router";
|
|
10
|
+
import {
|
|
11
|
+
AlertCircle,
|
|
12
|
+
ArrowLeft,
|
|
13
|
+
Building2,
|
|
14
|
+
Code2,
|
|
15
|
+
DatabaseBackup,
|
|
16
|
+
FileText,
|
|
17
|
+
Mail,
|
|
18
|
+
Menu,
|
|
19
|
+
Package,
|
|
20
|
+
ScrollText,
|
|
21
|
+
Shield,
|
|
22
|
+
Signpost,
|
|
23
|
+
SlidersHorizontal,
|
|
24
|
+
Users,
|
|
25
|
+
} from "lucide-react";
|
|
26
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
27
|
+
import { useServerFn } from "@tanstack/react-start";
|
|
28
|
+
|
|
29
|
+
import type { SiteSettingOverrides } from "../../config/site-config";
|
|
30
|
+
import { AuditView } from "../AuditView";
|
|
31
|
+
import { BackupView } from "../BackupView";
|
|
32
|
+
import { DomainsView } from "../DomainsView";
|
|
33
|
+
import { EmailSettingsView } from "../EmailSettingsView";
|
|
34
|
+
import { NavigationView } from "../NavigationView";
|
|
35
|
+
import { ReviewsView, type ReviewsSection } from "../ReviewsView";
|
|
36
|
+
import { ContactDetailsView } from "../ContactDetailsView";
|
|
37
|
+
import { EmailLogsView } from "../EmailLogsView";
|
|
38
|
+
import { FormSettingsView } from "../FormSettingsView";
|
|
39
|
+
import { LogsView } from "../LogsView";
|
|
40
|
+
import { RedirectsView } from "../RedirectsView";
|
|
41
|
+
import { RolesView } from "../RolesView";
|
|
42
|
+
import { SiteSettingsView } from "../SiteSettingsView";
|
|
43
|
+
import { TrackingView } from "../TrackingView";
|
|
44
|
+
import { UsersView } from "../UsersView";
|
|
45
|
+
import { VersionView } from "../VersionView";
|
|
46
|
+
import { backofficeHost, backofficePaths } from "./host";
|
|
47
|
+
|
|
48
|
+
export * from "./host";
|
|
49
|
+
|
|
50
|
+
/** Audit trail screen. */
|
|
51
|
+
export function AuditRoute() {
|
|
52
|
+
const { fns } = backofficeHost();
|
|
53
|
+
const fetchLogs = useServerFn(fns.listAuditLogs);
|
|
54
|
+
const q = useQuery({
|
|
55
|
+
queryKey: ["admin", "audit"],
|
|
56
|
+
queryFn: () => fetchLogs({ data: { limit: 300 } }),
|
|
57
|
+
});
|
|
58
|
+
return <AuditView rows={(q.data ?? []) as never} />;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Outbound email log screen. */
|
|
62
|
+
export function EmailLogsRoute() {
|
|
63
|
+
const { fns } = backofficeHost();
|
|
64
|
+
const fetchLogs = useServerFn(fns.listEmailLogs);
|
|
65
|
+
const q = useQuery({
|
|
66
|
+
queryKey: ["admin", "email-logs"],
|
|
67
|
+
queryFn: () => fetchLogs({ data: { limit: 300 } }),
|
|
68
|
+
});
|
|
69
|
+
return <EmailLogsView rows={(q.data ?? []) as never} />;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Log index linking the audit trail and email log. */
|
|
73
|
+
export function LogsRoute() {
|
|
74
|
+
const paths = backofficePaths();
|
|
75
|
+
return <LogsView auditPath={paths.audit} emailLogsPath={paths.emailLogs} />;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Role reference screen. */
|
|
79
|
+
export function RolesRoute() {
|
|
80
|
+
const host = backofficeHost();
|
|
81
|
+
return <RolesView allRoles={host.allRoles} usersPath={backofficePaths(host).users} />;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** User administration screen. */
|
|
85
|
+
export function UsersRoute() {
|
|
86
|
+
const { fns, allRoles } = backofficeHost();
|
|
87
|
+
const qc = useQueryClient();
|
|
88
|
+
const fetchUsers = useServerFn(fns.listUsers);
|
|
89
|
+
const fetchMyRoles = useServerFn(fns.getMyRoles);
|
|
90
|
+
const runCreate = useServerFn(fns.createUser);
|
|
91
|
+
const runDelete = useServerFn(fns.deleteUser);
|
|
92
|
+
const runSetPw = useServerFn(fns.setUserPassword);
|
|
93
|
+
const runReset = useServerFn(fns.sendPasswordResetEmail);
|
|
94
|
+
const runSetRoles = useServerFn(fns.setUserRoles);
|
|
95
|
+
|
|
96
|
+
const usersQ = useQuery({ queryKey: ["admin", "users"], queryFn: () => fetchUsers() });
|
|
97
|
+
const myRolesQ = useQuery({ queryKey: ["admin", "my-roles"], queryFn: () => fetchMyRoles() });
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<UsersView
|
|
101
|
+
deps={{
|
|
102
|
+
users: (usersQ.data ?? []) as never,
|
|
103
|
+
myRoles: myRolesQ.data ?? [],
|
|
104
|
+
allRoles,
|
|
105
|
+
createUser: (input) => runCreate(input as never),
|
|
106
|
+
deleteUser: (input) => runDelete(input),
|
|
107
|
+
setUserPassword: (input) => runSetPw(input),
|
|
108
|
+
sendPasswordResetEmail: (input) => runReset(input),
|
|
109
|
+
setUserRoles: (input) => runSetRoles(input as never),
|
|
110
|
+
refresh: () => qc.invalidateQueries({ queryKey: ["admin", "users"] }),
|
|
111
|
+
}}
|
|
112
|
+
/>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** CMS build and platform version screen. */
|
|
117
|
+
export function VersionRoute() {
|
|
118
|
+
const { fns } = backofficeHost();
|
|
119
|
+
const fetchVersion = useServerFn(fns.getSystemVersion);
|
|
120
|
+
const q = useQuery({ queryKey: ["admin", "system-version"], queryFn: () => fetchVersion() });
|
|
121
|
+
return (
|
|
122
|
+
<VersionView
|
|
123
|
+
deps={{ build: q.data?.build, releases: q.data?.releases ?? [], loading: q.isLoading }}
|
|
124
|
+
/>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Site settings screen (code defaults plus database overrides). */
|
|
129
|
+
export function SiteSettingsRoute() {
|
|
130
|
+
const { fns, siteConfig } = backofficeHost();
|
|
131
|
+
const load = useServerFn(fns.getSiteSettings);
|
|
132
|
+
const save = useServerFn(fns.saveSiteSettings);
|
|
133
|
+
const clear = useServerFn(fns.clearSiteSetting);
|
|
134
|
+
const q = useQuery({ queryKey: ["admin", "site-settings"], queryFn: () => load() });
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<SiteSettingsView
|
|
138
|
+
deps={{
|
|
139
|
+
config: q.data?.config ?? siteConfig,
|
|
140
|
+
overrides: (q.data?.overrides ?? {}) as SiteSettingOverrides,
|
|
141
|
+
saveSettings: (input) => save(input as never),
|
|
142
|
+
clearSetting: (input) => clear(input as never),
|
|
143
|
+
refresh: () => q.refetch(),
|
|
144
|
+
originSource: q.data?.originSource ?? "code",
|
|
145
|
+
}}
|
|
146
|
+
/>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** URL redirect administration. */
|
|
151
|
+
export function RedirectsRoute() {
|
|
152
|
+
const { fns } = backofficeHost();
|
|
153
|
+
const list = useServerFn(fns.listRedirects);
|
|
154
|
+
const save = useServerFn(fns.saveRedirect);
|
|
155
|
+
const remove = useServerFn(fns.deleteRedirect);
|
|
156
|
+
const q = useQuery({ queryKey: ["cms", "redirects"], queryFn: () => list() });
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<RedirectsView
|
|
160
|
+
deps={{
|
|
161
|
+
rows: q.data ?? [],
|
|
162
|
+
saveRedirect: (input) => save(input),
|
|
163
|
+
deleteRedirect: (input) => remove(input),
|
|
164
|
+
refresh: () => q.refetch(),
|
|
165
|
+
}}
|
|
166
|
+
/>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Code & tracking injection screen. */
|
|
171
|
+
export function TrackingRoute() {
|
|
172
|
+
const { fns, locales } = backofficeHost();
|
|
173
|
+
const list = useServerFn(fns.listInjections);
|
|
174
|
+
const save = useServerFn(fns.saveInjection);
|
|
175
|
+
const remove = useServerFn(fns.deleteInjection);
|
|
176
|
+
const domains = useServerFn(fns.getLocaleDomains);
|
|
177
|
+
|
|
178
|
+
const q = useQuery({ queryKey: ["admin", "injections"], queryFn: () => list() });
|
|
179
|
+
const domainsQ = useQuery({ queryKey: ["admin", "locale-domains"], queryFn: () => domains() });
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<TrackingView
|
|
183
|
+
deps={{
|
|
184
|
+
profiles: q.data ?? [],
|
|
185
|
+
domains: domainsQ.data ?? [],
|
|
186
|
+
locales: locales as never,
|
|
187
|
+
saveInjection: (input) => save(input as never),
|
|
188
|
+
deleteInjection: (input) => remove(input),
|
|
189
|
+
refresh: () => q.refetch(),
|
|
190
|
+
}}
|
|
191
|
+
/>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Company contact details screen. */
|
|
196
|
+
export function ContactDetailsRoute() {
|
|
197
|
+
const { fns, company } = backofficeHost();
|
|
198
|
+
const fetchOverrides = useServerFn(fns.getContentOverrides);
|
|
199
|
+
const save = useServerFn(fns.saveContentSection);
|
|
200
|
+
const qc = useQueryClient();
|
|
201
|
+
const overridesQ = useQuery({
|
|
202
|
+
queryKey: ["cms", "content-overrides"],
|
|
203
|
+
queryFn: () => fetchOverrides(),
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<ContactDetailsView
|
|
208
|
+
deps={{
|
|
209
|
+
defaults: company,
|
|
210
|
+
stored: overridesQ.data?.company as Record<string, string> | undefined,
|
|
211
|
+
loading: overridesQ.isLoading,
|
|
212
|
+
saveSection: (input) => save(input as never),
|
|
213
|
+
refresh: () => qc.invalidateQueries({ queryKey: ["cms", "content-overrides"] }),
|
|
214
|
+
}}
|
|
215
|
+
/>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Form defaults and retention screen. */
|
|
220
|
+
export function FormSettingsRoute() {
|
|
221
|
+
const host = backofficeHost();
|
|
222
|
+
const { fns } = host;
|
|
223
|
+
const load = useServerFn(fns.getFormDefaults);
|
|
224
|
+
const save = useServerFn(fns.saveFormDefaults);
|
|
225
|
+
const purge = useServerFn(fns.purgeOldSubmissions);
|
|
226
|
+
const formsPath = backofficePaths(host).forms;
|
|
227
|
+
const { data, isLoading } = useQuery({ queryKey: ["form-defaults"], queryFn: () => load() });
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<FormSettingsView
|
|
231
|
+
deps={{
|
|
232
|
+
defaults: data,
|
|
233
|
+
isLoading,
|
|
234
|
+
saveDefaults: (input) => save(input as never),
|
|
235
|
+
purgeOldSubmissions: (input) => purge(input),
|
|
236
|
+
previewLocale: host.previewLocale ?? host.siteConfig.defaultLocale,
|
|
237
|
+
BackLink: ({ className, children }) => (
|
|
238
|
+
<Link to={formsPath} className={className}>
|
|
239
|
+
{children}
|
|
240
|
+
</Link>
|
|
241
|
+
),
|
|
242
|
+
}}
|
|
243
|
+
/>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Shared-content SEO detail helper: back navigation to the SEO list. */
|
|
248
|
+
export function useSeoListNavigate() {
|
|
249
|
+
const navigate = useNavigate();
|
|
250
|
+
const paths = backofficePaths();
|
|
251
|
+
return () => navigate({ to: `${paths.base}/seo` });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/* ------------------------------------------------------------------ *
|
|
255
|
+
* Admin section: layout, overview and the remaining admin screens.
|
|
256
|
+
* ------------------------------------------------------------------ */
|
|
257
|
+
|
|
258
|
+
/** Chrome around every `/admin/*` screen: a back link plus the section title. */
|
|
259
|
+
export function AdminLayoutRoute() {
|
|
260
|
+
const paths = backofficePaths();
|
|
261
|
+
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
|
262
|
+
const isOverview = pathname.replace(/\/$/, "") === paths.admin;
|
|
263
|
+
const backTo = isOverview ? paths.base : paths.admin;
|
|
264
|
+
const backLabel = isOverview ? "Back to backoffice" : "Back to admin";
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<div className="min-h-screen">
|
|
268
|
+
<div className="border-b border-ink/10 bg-white">
|
|
269
|
+
<div className="px-8 md:px-12 py-6">
|
|
270
|
+
<Link
|
|
271
|
+
to={backTo}
|
|
272
|
+
className="inline-flex items-center gap-2 text-[0.7rem] uppercase tracking-[0.22em] text-ink/55 hover:text-sea transition-colors"
|
|
273
|
+
>
|
|
274
|
+
<ArrowLeft className="size-3.5" />
|
|
275
|
+
{backLabel}
|
|
276
|
+
</Link>
|
|
277
|
+
<h1 className="mt-3 font-serif text-2xl text-ink">System administration</h1>
|
|
278
|
+
</div>
|
|
279
|
+
</div>
|
|
280
|
+
<Outlet />
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Card grid linking to every admin screen the signed-in user may open. */
|
|
286
|
+
export function AdminOverviewRoute() {
|
|
287
|
+
const host = backofficeHost();
|
|
288
|
+
const paths = backofficePaths(host);
|
|
289
|
+
const fetchRoles = useServerFn(host.fns.getMyRoles);
|
|
290
|
+
const rolesQ = useQuery({ queryKey: ["admin", "my-roles"], queryFn: () => fetchRoles() });
|
|
291
|
+
const isSuper = ((rolesQ.data ?? []) as string[]).includes("superadmin");
|
|
292
|
+
|
|
293
|
+
const cards = [
|
|
294
|
+
{
|
|
295
|
+
to: paths.settings,
|
|
296
|
+
title: "Site settings",
|
|
297
|
+
body: "Site name, company details, active modules and brand tokens — with reset to code defaults.",
|
|
298
|
+
icon: SlidersHorizontal,
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
to: paths.users,
|
|
302
|
+
title: "Users",
|
|
303
|
+
body: "Create, edit, delete users and reset passwords.",
|
|
304
|
+
icon: Users,
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
to: paths.roles,
|
|
308
|
+
title: "User roles",
|
|
309
|
+
body: "Assign superadmin, admin, seo or user roles.",
|
|
310
|
+
icon: Shield,
|
|
311
|
+
},
|
|
312
|
+
{ to: paths.logs, title: "Logs", body: "System logs (admin+).", icon: ScrollText },
|
|
313
|
+
{
|
|
314
|
+
to: paths.emailLogs,
|
|
315
|
+
title: "Email logs",
|
|
316
|
+
body: "Outgoing email history.",
|
|
317
|
+
icon: FileText,
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
to: paths.audit,
|
|
321
|
+
title: "Audit logs",
|
|
322
|
+
body: "Every backoffice action.",
|
|
323
|
+
icon: AlertCircle,
|
|
324
|
+
},
|
|
325
|
+
...(host.siteConfig.features.email
|
|
326
|
+
? [
|
|
327
|
+
{
|
|
328
|
+
to: paths.email,
|
|
329
|
+
title: "Email settings",
|
|
330
|
+
body: "SMTP / Amazon SES, from/reply-to.",
|
|
331
|
+
icon: Mail,
|
|
332
|
+
},
|
|
333
|
+
]
|
|
334
|
+
: []),
|
|
335
|
+
{
|
|
336
|
+
to: paths.contact,
|
|
337
|
+
title: "Contact details",
|
|
338
|
+
body: "Company address, org. number, email and phone.",
|
|
339
|
+
icon: Building2,
|
|
340
|
+
},
|
|
341
|
+
...(host.siteConfig.features.tracking
|
|
342
|
+
? [
|
|
343
|
+
{
|
|
344
|
+
to: paths.tracking,
|
|
345
|
+
title: "Code & tracking",
|
|
346
|
+
body: "Head/body code injection, GTM, GA4, Meta and TikTok pixels.",
|
|
347
|
+
icon: Code2,
|
|
348
|
+
},
|
|
349
|
+
]
|
|
350
|
+
: []),
|
|
351
|
+
{
|
|
352
|
+
to: paths.redirects,
|
|
353
|
+
title: "Redirects",
|
|
354
|
+
body: "Old URLs pointing to new ones, created automatically on slug changes.",
|
|
355
|
+
icon: Signpost,
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
to: paths.navigation,
|
|
359
|
+
title: "Navigation & footer",
|
|
360
|
+
body: "Site-wide top menu and footer content.",
|
|
361
|
+
icon: Menu,
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
to: paths.version,
|
|
365
|
+
title: "CMS & platform version",
|
|
366
|
+
body: "Current CMS build, shared platform package version and release history with dates.",
|
|
367
|
+
icon: Package,
|
|
368
|
+
},
|
|
369
|
+
...(isSuper && host.siteConfig.features.backup
|
|
370
|
+
? [
|
|
371
|
+
{
|
|
372
|
+
to: paths.backup,
|
|
373
|
+
title: "Backup & restore",
|
|
374
|
+
body: "Export everything — content, forms, submissions, media and roles — and restore it again.",
|
|
375
|
+
icon: DatabaseBackup,
|
|
376
|
+
},
|
|
377
|
+
]
|
|
378
|
+
: []),
|
|
379
|
+
];
|
|
380
|
+
|
|
381
|
+
return (
|
|
382
|
+
<div className="p-8 md:p-12 w-full">
|
|
383
|
+
<div className="grid gap-4 md:grid-cols-3">
|
|
384
|
+
{cards.map((c) => (
|
|
385
|
+
<Link
|
|
386
|
+
key={c.to}
|
|
387
|
+
to={c.to}
|
|
388
|
+
className="group border border-ink/10 bg-white p-6 hover:border-sea transition-colors"
|
|
389
|
+
>
|
|
390
|
+
<c.icon className="size-6 text-ink/70 group-hover:text-sea" />
|
|
391
|
+
<div className="mt-4 font-serif text-lg text-ink">{c.title}</div>
|
|
392
|
+
<p className="mt-2 text-sm text-ink/60">{c.body}</p>
|
|
393
|
+
</Link>
|
|
394
|
+
))}
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Email sending settings plus provider credentials. */
|
|
401
|
+
export function EmailSettingsRoute() {
|
|
402
|
+
const host = backofficeHost();
|
|
403
|
+
const { fns } = host;
|
|
404
|
+
const fetchSettings = useServerFn(fns.getEmailSettings);
|
|
405
|
+
const fetchRoles = useServerFn(fns.getMyRoles);
|
|
406
|
+
const fetchProviders = useServerFn(fns.listEmailProviders);
|
|
407
|
+
const save = useServerFn(fns.saveEmailSettings);
|
|
408
|
+
const saveProvider = useServerFn(fns.upsertEmailProvider);
|
|
409
|
+
const removeProvider = useServerFn(fns.deleteEmailProvider);
|
|
410
|
+
const sendTest = useServerFn(fns.sendTestEmail);
|
|
411
|
+
|
|
412
|
+
const settingsQ = useQuery({
|
|
413
|
+
queryKey: ["admin", "email-settings"],
|
|
414
|
+
queryFn: () => fetchSettings(),
|
|
415
|
+
});
|
|
416
|
+
const providersQ = useQuery({
|
|
417
|
+
queryKey: ["admin", "email-providers"],
|
|
418
|
+
queryFn: () => fetchProviders(),
|
|
419
|
+
});
|
|
420
|
+
const rolesQ = useQuery({ queryKey: ["admin", "my-roles"], queryFn: () => fetchRoles() });
|
|
421
|
+
|
|
422
|
+
return (
|
|
423
|
+
<EmailSettingsView
|
|
424
|
+
deps={{
|
|
425
|
+
settings: (settingsQ.data ?? null) as never,
|
|
426
|
+
providers: (providersQ.data ?? []) as never,
|
|
427
|
+
isSuper: ((rolesQ.data ?? []) as string[]).includes("superadmin"),
|
|
428
|
+
loadingProviders: providersQ.isLoading,
|
|
429
|
+
saveSettings: (input) => save(input as never),
|
|
430
|
+
saveProvider: (input) => saveProvider(input as never),
|
|
431
|
+
deleteProvider: (input) => removeProvider(input),
|
|
432
|
+
sendTestEmail: (input) => sendTest(input),
|
|
433
|
+
refreshSettings: () => settingsQ.refetch(),
|
|
434
|
+
refreshProviders: () => providersQ.refetch(),
|
|
435
|
+
placeholders: {
|
|
436
|
+
fromEmail: host.placeholders?.fromEmail ?? "",
|
|
437
|
+
notifyTo: host.placeholders?.notifyTo ?? "",
|
|
438
|
+
},
|
|
439
|
+
}}
|
|
440
|
+
/>
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Site-wide navigation and footer editor. */
|
|
445
|
+
export function NavigationRoute() {
|
|
446
|
+
const host = backofficeHost();
|
|
447
|
+
const qc = useQueryClient();
|
|
448
|
+
const site = host.content.useSite();
|
|
449
|
+
const overrides = host.content.useOverrides();
|
|
450
|
+
const save = useServerFn(host.fns.saveContentSection);
|
|
451
|
+
|
|
452
|
+
return (
|
|
453
|
+
<NavigationView
|
|
454
|
+
deps={{
|
|
455
|
+
overrides,
|
|
456
|
+
site,
|
|
457
|
+
defaults: host.content.defaults,
|
|
458
|
+
SectionForm: host.components.SectionForm,
|
|
459
|
+
saveSection: (input) => save(input as never),
|
|
460
|
+
refresh: () => qc.invalidateQueries({ queryKey: host.content.overridesQueryKey }),
|
|
461
|
+
}}
|
|
462
|
+
/>
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Per-locale domain mapping. */
|
|
467
|
+
export function DomainsRoute() {
|
|
468
|
+
const host = backofficeHost();
|
|
469
|
+
const paths = backofficePaths(host);
|
|
470
|
+
const qc = useQueryClient();
|
|
471
|
+
const list = useServerFn(host.fns.getLocaleDomains);
|
|
472
|
+
const save = useServerFn(host.fns.saveLocaleDomain);
|
|
473
|
+
const del = useServerFn(host.fns.deleteLocaleDomain);
|
|
474
|
+
const q = useQuery({ queryKey: ["locale-domains"], queryFn: () => list() });
|
|
475
|
+
|
|
476
|
+
return (
|
|
477
|
+
<DomainsView
|
|
478
|
+
deps={{
|
|
479
|
+
domains: q.data ?? [],
|
|
480
|
+
locales: host.siteConfig.locales,
|
|
481
|
+
defaultLocale: host.siteConfig.defaultLocale,
|
|
482
|
+
backPath: paths.base,
|
|
483
|
+
domainPlaceholder: host.placeholders?.domain ?? "",
|
|
484
|
+
saveDomain: (input) => save(input as never),
|
|
485
|
+
deleteDomain: (input) => del(input as never),
|
|
486
|
+
refresh: () => qc.invalidateQueries({ queryKey: ["locale-domains"] }),
|
|
487
|
+
}}
|
|
488
|
+
/>
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Testimonials / reviews editor backed by the shared content section. */
|
|
493
|
+
export function ReviewsRoute() {
|
|
494
|
+
const host = backofficeHost();
|
|
495
|
+
const paths = backofficePaths(host);
|
|
496
|
+
const qc = useQueryClient();
|
|
497
|
+
const site = host.content.useSite();
|
|
498
|
+
const overrides = host.content.useOverrides();
|
|
499
|
+
const save = useServerFn(host.fns.saveContentSection);
|
|
500
|
+
|
|
501
|
+
const source = (overrides?.["testimonials"] ??
|
|
502
|
+
site["testimonials"] ??
|
|
503
|
+
host.content.defaultForSection("testimonials")) as ReviewsSection;
|
|
504
|
+
|
|
505
|
+
return (
|
|
506
|
+
<ReviewsView
|
|
507
|
+
deps={{
|
|
508
|
+
source,
|
|
509
|
+
locale: host.previewLocale ?? host.siteConfig.defaultLocale,
|
|
510
|
+
save: async (section) => {
|
|
511
|
+
await save({
|
|
512
|
+
data: { key: "testimonials", data: section as unknown as Record<string, unknown> },
|
|
513
|
+
});
|
|
514
|
+
await qc.invalidateQueries({ queryKey: host.content.overridesQueryKey });
|
|
515
|
+
},
|
|
516
|
+
MediaField: host.components.MediaField as never,
|
|
517
|
+
ReviewImageUpload: host.components.ReviewImageUpload as never,
|
|
518
|
+
BackLink: ({ className, children }) => (
|
|
519
|
+
<Link to={paths.base} className={className}>
|
|
520
|
+
{children}
|
|
521
|
+
</Link>
|
|
522
|
+
),
|
|
523
|
+
}}
|
|
524
|
+
/>
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Backup and restore, superadmin only. */
|
|
529
|
+
export function BackupRoute() {
|
|
530
|
+
const host = backofficeHost();
|
|
531
|
+
const backup = host.backup;
|
|
532
|
+
if (!backup) {
|
|
533
|
+
throw new Error("Backup host adapters missing — register `backup` to use BackupRoute.");
|
|
534
|
+
}
|
|
535
|
+
const fetchRoles = useServerFn(host.fns.getMyRoles);
|
|
536
|
+
const rolesQ = useQuery({
|
|
537
|
+
queryKey: ["admin", "my-roles"],
|
|
538
|
+
queryFn: async () => (await fetchRoles()) as string[],
|
|
539
|
+
retry: 3,
|
|
540
|
+
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 4000),
|
|
541
|
+
staleTime: 60_000,
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
return (
|
|
545
|
+
<BackupView
|
|
546
|
+
deps={{
|
|
547
|
+
isSuper: ((rolesQ.data ?? []) as string[]).includes("superadmin"),
|
|
548
|
+
loading: rolesQ.isLoading,
|
|
549
|
+
error: rolesQ.isError,
|
|
550
|
+
retry: () => void rolesQ.refetch(),
|
|
551
|
+
authHeaders: backup.authHeaders,
|
|
552
|
+
buildBrowserMediaArchive: backup.buildBrowserMediaArchive as never,
|
|
553
|
+
}}
|
|
554
|
+
/>
|
|
555
|
+
);
|
|
556
|
+
}
|