@bygghammar/cms-platform 0.63.0 → 0.64.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/package.json +1 -1
- package/src/backoffice/DashboardView.tsx +2 -0
- package/src/backoffice/routes/host.ts +2 -0
- package/src/backoffice/routes/index.tsx +49 -6
- package/src/backoffice/routes/media-forms.tsx +33 -4
- package/src/backoffice/routes/pages.tsx +7 -1
- package/src/backoffice/shell/index.ts +8 -0
- package/src/backoffice/shell/use-resolved-config.tsx +84 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.64.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": [
|
|
@@ -14,6 +14,8 @@ export type DashboardCard = {
|
|
|
14
14
|
title: string;
|
|
15
15
|
body: string;
|
|
16
16
|
icon: ComponentType<{ className?: string }>;
|
|
17
|
+
/** Module switch that has to be on for the card to be shown. */
|
|
18
|
+
feature?: string;
|
|
17
19
|
};
|
|
18
20
|
|
|
19
21
|
/** Translation sweep modes offered on the dashboard. */
|
|
@@ -232,6 +232,8 @@ export type BackofficeHost = {
|
|
|
232
232
|
title: string;
|
|
233
233
|
body: string;
|
|
234
234
|
icon: (props: { className?: string }) => ReactNode;
|
|
235
|
+
/** Module switch that has to be on for the card to be shown. */
|
|
236
|
+
feature?: string;
|
|
235
237
|
}[];
|
|
236
238
|
translateTarget?: string;
|
|
237
239
|
translate?: HostServerFn;
|
|
@@ -45,6 +45,7 @@ import { SiteSettingsView } from "../SiteSettingsView";
|
|
|
45
45
|
import { TrackingView } from "../TrackingView";
|
|
46
46
|
import { UsersView } from "../UsersView";
|
|
47
47
|
import { VersionView } from "../VersionView";
|
|
48
|
+
import { useFeatureGate } from "../shell/use-resolved-config";
|
|
48
49
|
import { backofficeHost, backofficePaths } from "./host";
|
|
49
50
|
|
|
50
51
|
export * from "./host";
|
|
@@ -189,7 +190,7 @@ export function RedirectsRoute() {
|
|
|
189
190
|
}
|
|
190
191
|
|
|
191
192
|
/** Code & tracking injection screen. */
|
|
192
|
-
|
|
193
|
+
function TrackingRouteInner() {
|
|
193
194
|
const { fns, locales } = backofficeHost();
|
|
194
195
|
const list = useServerFn(fns.listInjections);
|
|
195
196
|
const save = useServerFn(fns.saveInjection);
|
|
@@ -238,7 +239,7 @@ export function ContactDetailsRoute() {
|
|
|
238
239
|
}
|
|
239
240
|
|
|
240
241
|
/** Form defaults and retention screen. */
|
|
241
|
-
|
|
242
|
+
function FormSettingsRouteInner() {
|
|
242
243
|
const host = backofficeHost();
|
|
243
244
|
const { fns } = host;
|
|
244
245
|
const load = useServerFn(fns.getFormDefaults);
|
|
@@ -493,7 +494,7 @@ export function SitemapRoute() {
|
|
|
493
494
|
}
|
|
494
495
|
|
|
495
496
|
/** Email sending settings plus provider credentials. */
|
|
496
|
-
|
|
497
|
+
function EmailSettingsRouteInner() {
|
|
497
498
|
const host = backofficeHost();
|
|
498
499
|
const { fns } = host;
|
|
499
500
|
const fetchSettings = useServerFn(fns.getEmailSettings);
|
|
@@ -559,7 +560,7 @@ export function NavigationRoute() {
|
|
|
559
560
|
}
|
|
560
561
|
|
|
561
562
|
/** Per-locale domain mapping. */
|
|
562
|
-
|
|
563
|
+
function DomainsRouteInner() {
|
|
563
564
|
const host = backofficeHost();
|
|
564
565
|
const paths = backofficePaths(host);
|
|
565
566
|
const qc = useQueryClient();
|
|
@@ -585,7 +586,7 @@ export function DomainsRoute() {
|
|
|
585
586
|
}
|
|
586
587
|
|
|
587
588
|
/** Testimonials / reviews editor backed by the shared content section. */
|
|
588
|
-
|
|
589
|
+
function ReviewsRouteInner() {
|
|
589
590
|
const host = backofficeHost();
|
|
590
591
|
const paths = backofficePaths(host);
|
|
591
592
|
const qc = useQueryClient();
|
|
@@ -621,7 +622,7 @@ export function ReviewsRoute() {
|
|
|
621
622
|
}
|
|
622
623
|
|
|
623
624
|
/** Backup and restore, superadmin only. */
|
|
624
|
-
|
|
625
|
+
function BackupRouteInner() {
|
|
625
626
|
const host = backofficeHost();
|
|
626
627
|
const backup = host.backup;
|
|
627
628
|
if (!backup) {
|
|
@@ -649,3 +650,45 @@ export function BackupRoute() {
|
|
|
649
650
|
/>
|
|
650
651
|
);
|
|
651
652
|
}
|
|
653
|
+
|
|
654
|
+
/** ReviewsRoute guarded by the "reviews" module switch. */
|
|
655
|
+
export function ReviewsRoute() {
|
|
656
|
+
const gate = useFeatureGate("reviews", "Reviews");
|
|
657
|
+
if (gate) return <>{gate}</>;
|
|
658
|
+
return <ReviewsRouteInner />;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** DomainsRoute guarded by the "domains" module switch. */
|
|
662
|
+
export function DomainsRoute() {
|
|
663
|
+
const gate = useFeatureGate("domains", "Domains");
|
|
664
|
+
if (gate) return <>{gate}</>;
|
|
665
|
+
return <DomainsRouteInner />;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** TrackingRoute guarded by the "tracking" module switch. */
|
|
669
|
+
export function TrackingRoute() {
|
|
670
|
+
const gate = useFeatureGate("tracking", "Code & tracking");
|
|
671
|
+
if (gate) return <>{gate}</>;
|
|
672
|
+
return <TrackingRouteInner />;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** EmailSettingsRoute guarded by the "email" module switch. */
|
|
676
|
+
export function EmailSettingsRoute() {
|
|
677
|
+
const gate = useFeatureGate("email", "Email");
|
|
678
|
+
if (gate) return <>{gate}</>;
|
|
679
|
+
return <EmailSettingsRouteInner />;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** BackupRoute guarded by the "backup" module switch. */
|
|
683
|
+
export function BackupRoute() {
|
|
684
|
+
const gate = useFeatureGate("backup", "Backup");
|
|
685
|
+
if (gate) return <>{gate}</>;
|
|
686
|
+
return <BackupRouteInner />;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** FormSettingsRoute guarded by the "forms" module switch. */
|
|
690
|
+
export function FormSettingsRoute() {
|
|
691
|
+
const gate = useFeatureGate("forms", "Forms");
|
|
692
|
+
if (gate) return <>{gate}</>;
|
|
693
|
+
return <FormSettingsRouteInner />;
|
|
694
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
10
10
|
|
|
11
|
+
import { useFeatureGate } from "../shell/use-resolved-config";
|
|
11
12
|
import { Link, useNavigate, useParams, useSearch } from "@tanstack/react-router";
|
|
12
13
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
13
14
|
import { useServerFn } from "@tanstack/react-start";
|
|
@@ -113,7 +114,7 @@ export function MediaRoute() {
|
|
|
113
114
|
}
|
|
114
115
|
|
|
115
116
|
/** Forms index: list, create, duplicate, delete and the trash bin. */
|
|
116
|
-
|
|
117
|
+
function FormsIndexRouteInner() {
|
|
117
118
|
const forms = requireHostGroup("forms");
|
|
118
119
|
const { fns } = backofficeHost();
|
|
119
120
|
const qc = useQueryClient();
|
|
@@ -158,7 +159,7 @@ export function FormsIndexRoute() {
|
|
|
158
159
|
}
|
|
159
160
|
|
|
160
161
|
/** Form editor for a single form key. */
|
|
161
|
-
|
|
162
|
+
function FormEditorRouteInner() {
|
|
162
163
|
const forms = requireHostGroup("forms");
|
|
163
164
|
const { fns } = backofficeHost();
|
|
164
165
|
const params = useParams({ strict: false }) as { key?: string };
|
|
@@ -231,7 +232,7 @@ export function validateSubmissionSearch(search: Record<string, unknown>): Submi
|
|
|
231
232
|
}
|
|
232
233
|
|
|
233
234
|
/** Submission inbox with filters, paging, exports and delivery retries. */
|
|
234
|
-
|
|
235
|
+
function FormSubmissionsRouteInner() {
|
|
235
236
|
const forms = requireHostGroup("forms");
|
|
236
237
|
const host = backofficeHost();
|
|
237
238
|
const paths = backofficePaths(host);
|
|
@@ -302,7 +303,7 @@ export function FormSubmissionsRoute() {
|
|
|
302
303
|
}
|
|
303
304
|
|
|
304
305
|
/** Delivery attempt dashboard (retry / resolve failed notifications). */
|
|
305
|
-
|
|
306
|
+
function FormDeliveriesRouteInner() {
|
|
306
307
|
const forms = requireHostGroup("forms");
|
|
307
308
|
const host = backofficeHost();
|
|
308
309
|
const paths = backofficePaths(host);
|
|
@@ -358,3 +359,31 @@ export function FormDeliveriesRoute() {
|
|
|
358
359
|
/>
|
|
359
360
|
);
|
|
360
361
|
}
|
|
362
|
+
|
|
363
|
+
/** FormsIndexRoute guarded by the "forms" module switch. */
|
|
364
|
+
export function FormsIndexRoute() {
|
|
365
|
+
const gate = useFeatureGate("forms", "Forms");
|
|
366
|
+
if (gate) return <>{gate}</>;
|
|
367
|
+
return <FormsIndexRouteInner />;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** FormEditorRoute guarded by the "forms" module switch. */
|
|
371
|
+
export function FormEditorRoute() {
|
|
372
|
+
const gate = useFeatureGate("forms", "Forms");
|
|
373
|
+
if (gate) return <>{gate}</>;
|
|
374
|
+
return <FormEditorRouteInner />;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** FormSubmissionsRoute guarded by the "forms" module switch. */
|
|
378
|
+
export function FormSubmissionsRoute() {
|
|
379
|
+
const gate = useFeatureGate("forms", "Forms");
|
|
380
|
+
if (gate) return <>{gate}</>;
|
|
381
|
+
return <FormSubmissionsRouteInner />;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** FormDeliveriesRoute guarded by the "forms" module switch. */
|
|
385
|
+
export function FormDeliveriesRoute() {
|
|
386
|
+
const gate = useFeatureGate("forms", "Forms");
|
|
387
|
+
if (gate) return <>{gate}</>;
|
|
388
|
+
return <FormDeliveriesRouteInner />;
|
|
389
|
+
}
|
|
@@ -17,6 +17,7 @@ import { toast } from "sonner";
|
|
|
17
17
|
import { PageBuilderView } from "../PageBuilderView";
|
|
18
18
|
import { SectionEditorView } from "../SectionEditorView";
|
|
19
19
|
import { DashboardView, type DashboardCard } from "../DashboardView";
|
|
20
|
+
import { useResolvedSiteConfig } from "../shell/use-resolved-config";
|
|
20
21
|
import { ResetPasswordView } from "../ResetPasswordView";
|
|
21
22
|
import { backofficeHost, backofficePaths, requireHostGroup } from "./host";
|
|
22
23
|
|
|
@@ -204,13 +205,18 @@ export function SectionEditorRoute() {
|
|
|
204
205
|
/** Backoffice dashboard for `/<backoffice>/`. */
|
|
205
206
|
export function DashboardRoute() {
|
|
206
207
|
const dashboard = requireHostGroup("dashboard");
|
|
208
|
+
const { config } = useResolvedSiteConfig();
|
|
207
209
|
const translateFn = dashboard.translate;
|
|
208
210
|
const runTranslate = useServerFn(translateFn ?? (async () => ({})));
|
|
209
211
|
|
|
210
212
|
return (
|
|
211
213
|
<DashboardView
|
|
212
214
|
deps={{
|
|
213
|
-
cards: dashboard.cards as unknown as readonly DashboardCard[]
|
|
215
|
+
cards: (dashboard.cards as unknown as readonly DashboardCard[]).filter(
|
|
216
|
+
(c) =>
|
|
217
|
+
!c.feature ||
|
|
218
|
+
(config.features as Record<string, boolean | undefined>)[c.feature] === true,
|
|
219
|
+
),
|
|
214
220
|
renderLink: ({ to, className, children }) => (
|
|
215
221
|
<Link to={to as any} className={className}>
|
|
216
222
|
{children}
|
|
@@ -7,3 +7,11 @@ export type {
|
|
|
7
7
|
export { backofficeNav } from "./nav-model";
|
|
8
8
|
export type { BackofficeNavItem, BackofficeNavKey, BackofficeNavOptions } from "./nav-model";
|
|
9
9
|
export { backofficeRedirectHost, isDevelopmentHost } from "./domain-guard";
|
|
10
|
+
export {
|
|
11
|
+
FeatureDisabledNotice,
|
|
12
|
+
SITE_SETTINGS_QUERY_KEY,
|
|
13
|
+
useBackofficeNav,
|
|
14
|
+
useFeatureEnabled,
|
|
15
|
+
useFeatureGate,
|
|
16
|
+
useResolvedSiteConfig,
|
|
17
|
+
} from "./use-resolved-config";
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live backoffice site configuration.
|
|
3
|
+
*
|
|
4
|
+
* The sidebar, the dashboard cards and the module screens must all obey the
|
|
5
|
+
* module switches saved under Site settings, not just the values compiled into
|
|
6
|
+
* `site.config.ts`. These hooks resolve the code config layered with the stored
|
|
7
|
+
* overrides through the host's `getSiteSettings` function, sharing the
|
|
8
|
+
* `["admin","site-settings"]` query key with the settings screen so a save
|
|
9
|
+
* updates the menu immediately.
|
|
10
|
+
*/
|
|
11
|
+
import type { ReactNode } from "react";
|
|
12
|
+
import { useQuery } from "@tanstack/react-query";
|
|
13
|
+
import { useServerFn } from "@tanstack/react-start";
|
|
14
|
+
|
|
15
|
+
import type { SiteConfig, SiteFeatureFlags } from "../../config/site-config";
|
|
16
|
+
import { resolveSiteConfig } from "../../config/site-config";
|
|
17
|
+
import { backofficeHost } from "../routes/host";
|
|
18
|
+
import { backofficeNav, type BackofficeNavItem, type BackofficeNavOptions } from "./nav-model";
|
|
19
|
+
|
|
20
|
+
/** Query key shared with the site-settings screen. */
|
|
21
|
+
export const SITE_SETTINGS_QUERY_KEY = ["admin", "site-settings"] as const;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Code config merged with the stored overrides. While loading, the code config
|
|
25
|
+
* is returned so the shell renders without a flash of empty navigation.
|
|
26
|
+
*/
|
|
27
|
+
export function useResolvedSiteConfig(): { config: SiteConfig; isLoading: boolean } {
|
|
28
|
+
const host = backofficeHost();
|
|
29
|
+
const load = useServerFn(host.fns.getSiteSettings);
|
|
30
|
+
const q = useQuery({
|
|
31
|
+
queryKey: SITE_SETTINGS_QUERY_KEY,
|
|
32
|
+
queryFn: () => load() as Promise<{ config?: SiteConfig; overrides?: unknown }>,
|
|
33
|
+
staleTime: 30_000,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const resolved =
|
|
37
|
+
(q.data?.config as SiteConfig | undefined) ??
|
|
38
|
+
(q.data?.overrides
|
|
39
|
+
? resolveSiteConfig(host.siteConfig, q.data.overrides as never)
|
|
40
|
+
: host.siteConfig);
|
|
41
|
+
|
|
42
|
+
return { config: resolved, isLoading: q.isLoading };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Sidebar model built from the live (override-aware) configuration. */
|
|
46
|
+
export function useBackofficeNav(options: BackofficeNavOptions = {}): BackofficeNavItem[] {
|
|
47
|
+
const { config } = useResolvedSiteConfig();
|
|
48
|
+
return backofficeNav(config, options);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether a module is enabled right now. `undefined` while settings load, so a
|
|
53
|
+
* caller can avoid flashing a "turned off" notice before the answer is known.
|
|
54
|
+
*/
|
|
55
|
+
export function useFeatureEnabled(flag: keyof SiteFeatureFlags): boolean | undefined {
|
|
56
|
+
const { config, isLoading } = useResolvedSiteConfig();
|
|
57
|
+
if (isLoading) return undefined;
|
|
58
|
+
return config.features[flag] === true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Notice shown instead of a screen whose module is switched off. */
|
|
62
|
+
export function FeatureDisabledNotice({ label }: { label?: string }) {
|
|
63
|
+
return (
|
|
64
|
+
<div className="p-8 md:p-12">
|
|
65
|
+
<h1 className="font-serif text-2xl text-ink">
|
|
66
|
+
{label ? `${label} is turned off` : "This module is turned off"}
|
|
67
|
+
</h1>
|
|
68
|
+
<p className="mt-3 max-w-md text-sm text-ink/65">
|
|
69
|
+
Turn it back on under Site settings → Modules to use this screen.
|
|
70
|
+
</p>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Guard for a module screen: returns a node to render instead of the screen
|
|
77
|
+
* when the module is disabled, and `null` when the screen may render.
|
|
78
|
+
*/
|
|
79
|
+
export function useFeatureGate(flag: keyof SiteFeatureFlags, label?: string): ReactNode | null {
|
|
80
|
+
const enabled = useFeatureEnabled(flag);
|
|
81
|
+
if (enabled === undefined) return <div className="p-8 text-sm text-ink/50">Loading…</div>;
|
|
82
|
+
if (enabled) return null;
|
|
83
|
+
return <FeatureDisabledNotice label={label} />;
|
|
84
|
+
}
|