@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.
@@ -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
+ }