@bygghammar/cms-platform 0.43.0 → 0.44.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 CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.44.0
4
+
5
+ - New `backoffice/routes/media-forms`: package-owned `MediaRoute`,
6
+ `FormsIndexRoute`, `FormEditorRoute`, `FormSubmissionsRoute` and
7
+ `FormDeliveriesRoute`, plus `validateSubmissionSearch`.
8
+ - Host contract gains optional `media` and `forms` adapter groups
9
+ (`requireHostGroup`) and `backofficePaths()` now also derives `media`,
10
+ `formSubmissions` and `formDeliveries`.
11
+
3
12
  ## 0.42.0
4
13
 
5
14
  - Backoffice host registry gains email-settings, locale-domain, shared-content,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.43.0",
3
+ "version": "0.44.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,
@@ -158,6 +158,7 @@
158
158
  "./backoffice/shell": "./src/backoffice/shell/index.ts",
159
159
  "./backoffice/routes": "./src/backoffice/routes/index.tsx",
160
160
  "./backoffice/routes/host": "./src/backoffice/routes/host.ts",
161
+ "./backoffice/routes/media-forms": "./src/backoffice/routes/media-forms.tsx",
161
162
  "./backoffice/inspectors": "./src/backoffice/inspectors/index.tsx"
162
163
  },
163
164
  "dependencies": {
@@ -44,6 +44,11 @@ import {
44
44
  type BlockPatchProps,
45
45
  } from "./blocks";
46
46
 
47
+ /** Link target for a shared-section editor; paths are site-configured strings. */
48
+ function sectionEditorPath(key: string) {
49
+ return `${backofficePaths().sections}/${key}` as never;
50
+ }
51
+
47
52
  /** Localized string map as the form block stores its copy. */
48
53
  type FormBlockCopy = {
49
54
  formKey?: string;
@@ -125,7 +130,7 @@ function SharedSectionEditor({ sectionKey }: { sectionKey: string }) {
125
130
  <InspectorSection title="Shared content">
126
131
  {known ? (
127
132
  <Link
128
- to={`${backofficePaths().sections}/${sectionKey}`}
133
+ to={sectionEditorPath(sectionKey)}
129
134
  className="inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm hover:border-sea"
130
135
  >
131
136
  Open “{sectionKey}” section
@@ -193,7 +198,7 @@ function SharedSectionEditor({ sectionKey }: { sectionKey: string }) {
193
198
  <RotateCcw className="size-3.5" /> Reset to default
194
199
  </button>
195
200
  <Link
196
- to={`${backofficePaths().sections}/${sectionKey}`}
201
+ to={sectionEditorPath(sectionKey)}
197
202
  className="ml-auto text-[0.7rem] underline text-ink/60 hover:text-ink"
198
203
  >
199
204
  Open full editor
@@ -395,7 +400,7 @@ function GlobalContentNotice({ blockType }: { blockType: string }) {
395
400
  editor for consistent styling across every page.
396
401
  </p>
397
402
  <Link
398
- to={`${backofficePaths().sections}/${blockType}`}
403
+ to={sectionEditorPath(blockType)}
399
404
  className="inline-flex items-center gap-2 border border-ink/20 px-3 py-1.5 text-sm hover:border-sea"
400
405
  >
401
406
  Open “{blockType}” section
@@ -122,8 +122,67 @@ export type BackofficeHost = {
122
122
  /* eslint-disable-next-line @typescript-eslint/no-explicit-any */
123
123
  buildBrowserMediaArchive: (args: any) => Promise<any>;
124
124
  };
125
+ /* eslint-disable @typescript-eslint/no-explicit-any */
126
+ /** Media library adapters: query options, server calls and site components. */
127
+ media?: {
128
+ /** TanStack Query options for the media library listing. */
129
+ libraryQueryOptions: any;
130
+ fns: {
131
+ deleteMedia: HostServerFn;
132
+ listTrash: HostServerFn;
133
+ restoreMedia: HostServerFn;
134
+ purgeMedia: HostServerFn;
135
+ emptyTrash: HostServerFn;
136
+ uploadMediaBundle: HostServerFn;
137
+ listMediaUsage: HostServerFn;
138
+ importUrlToMedia: HostServerFn;
139
+ };
140
+ components: {
141
+ FocalImage: any;
142
+ FocusPicker: any;
143
+ MediaMetaDialog: any;
144
+ MediaReplaceDialog: any;
145
+ };
146
+ /** Hook returning the site's media metadata map (alt/caption/credits). */
147
+ useMetaMap: () => any;
148
+ /** Superadmin maintenance tools rendered next to Upload. */
149
+ maintenanceTools?: (args: { onDone: () => Promise<void> }) => ReactNode;
150
+ };
151
+ /** Forms module adapters: server calls plus the editor panels. */
152
+ forms?: {
153
+ fns: {
154
+ createFormFromType: HostServerFn;
155
+ deleteForm: HostServerFn;
156
+ duplicateForm: HostServerFn;
157
+ getFormUsage: HostServerFn;
158
+ listDeletedForms: HostServerFn;
159
+ purgeForm: HostServerFn;
160
+ restoreForm: HostServerFn;
161
+ saveForm: HostServerFn;
162
+ syncSubmissionsToSheet: HostServerFn;
163
+ testWebhook: HostServerFn;
164
+ listSubmissions: HostServerFn;
165
+ setSubmissionStatus: HostServerFn;
166
+ pushSubmissionsToSheet: HostServerFn;
167
+ resendDelivery: HostServerFn;
168
+ listPartials: HostServerFn;
169
+ deletePartials: HostServerFn;
170
+ listDeliveryAttempts: HostServerFn;
171
+ retryDeliveryAttempts: HostServerFn;
172
+ resolveDeliveryAttempts: HostServerFn;
173
+ };
174
+ components: {
175
+ FieldEditor: any;
176
+ FormPreview: any;
177
+ TestDeliveryPanel: any;
178
+ VersionHistory: any;
179
+ ActivityLog: any;
180
+ };
181
+ };
182
+ /* eslint-enable @typescript-eslint/no-explicit-any */
125
183
  };
126
184
 
185
+
127
186
  let current: BackofficeHost | null = null;
128
187
 
129
188
  /** Registers the host adapters. Call once, from a module the routes import. */
@@ -164,5 +223,21 @@ export function backofficePaths(host: BackofficeHost = backofficeHost()) {
164
223
  backup: `${base}/admin/backup`,
165
224
  sections: `${base}/sections`,
166
225
  globalSections: `${base}/global-sections`,
226
+ media: `${base}/media`,
227
+ formSubmissions: `${base}/forms/submissions`,
228
+ formDeliveries: `${base}/forms/deliveries`,
167
229
  };
168
230
  }
231
+
232
+ /** Reads a required optional host group, failing loudly when it is missing. */
233
+ export function requireHostGroup<K extends "media" | "forms">(
234
+ key: K,
235
+ ): NonNullable<BackofficeHost[K]> {
236
+ const group = backofficeHost()[key];
237
+ if (!group) {
238
+ throw new Error(
239
+ `Backoffice host is missing the "${key}" adapters — add them to registerBackofficeHost().`,
240
+ );
241
+ }
242
+ return group as NonNullable<BackofficeHost[K]>;
243
+ }
@@ -46,6 +46,7 @@ import { VersionView } from "../VersionView";
46
46
  import { backofficeHost, backofficePaths } from "./host";
47
47
 
48
48
  export * from "./host";
49
+ export * from "./media-forms";
49
50
 
50
51
  /** Audit trail screen. */
51
52
  export function AuditRoute() {
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Package-owned media library and forms routes.
3
+ *
4
+ * Each component reads the media/forms adapters from the backoffice host
5
+ * registry, wires the queries and renders the shared view, so a site route file
6
+ * stays a two-line `createFileRoute(...)({ component: XRoute })`.
7
+ */
8
+
9
+ /* eslint-disable @typescript-eslint/no-explicit-any */
10
+
11
+ import { Link, useNavigate, useParams, useSearch } from "@tanstack/react-router";
12
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
13
+ import { useServerFn } from "@tanstack/react-start";
14
+ import { useMemo, useState } from "react";
15
+
16
+ import { MediaLibraryView, type MediaLibraryDeps } from "../MediaLibraryView";
17
+ import { FormsIndexView } from "../FormsIndexView";
18
+ import { FormEditorView } from "../FormEditorView";
19
+ import { FormSubmissionsView, SUBMISSIONS_PAGE_SIZE } from "../FormSubmissionsView";
20
+ import { FormDeliveriesView, type DeliveryChannel } from "../FormDeliveriesView";
21
+ import { backofficeHost, backofficePaths, requireHostGroup } from "./host";
22
+
23
+ /** Router-agnostic link that forwards loose `to`/`params`/`search` values. */
24
+ function HostLink({
25
+ to,
26
+ params,
27
+ search,
28
+ className,
29
+ onClick,
30
+ children,
31
+ }: {
32
+ to: string;
33
+ params?: unknown;
34
+ search?: unknown;
35
+ className?: string;
36
+ onClick?: () => void;
37
+ children?: React.ReactNode;
38
+ }) {
39
+ return (
40
+ <Link
41
+ to={to as any}
42
+ params={params as any}
43
+ search={search as any}
44
+ className={className}
45
+ onClick={onClick}
46
+ >
47
+ {children}
48
+ </Link>
49
+ );
50
+ }
51
+
52
+ /** Media library screen (library, trash, usage and maintenance tools). */
53
+ export function MediaRoute() {
54
+ const media = requireHostGroup("media");
55
+ const { fns: adminFns } = backofficeHost();
56
+ const queryClient = useQueryClient();
57
+
58
+ const { data: items = [], isLoading } = useQuery(media.libraryQueryOptions);
59
+
60
+ const del = useServerFn(media.fns.deleteMedia);
61
+ const fetchUsage = useServerFn(media.fns.listMediaUsage);
62
+ const runImport = useServerFn(media.fns.importUrlToMedia);
63
+ const uploadBundle = useServerFn(media.fns.uploadMediaBundle);
64
+ const fetchTrash = useServerFn(media.fns.listTrash);
65
+ const fetchRoles = useServerFn(adminFns.getMyRoles);
66
+ const restore = useServerFn(media.fns.restoreMedia);
67
+ const purge = useServerFn(media.fns.purgeMedia);
68
+ const empty = useServerFn(media.fns.emptyTrash);
69
+
70
+ const usageQ = useQuery({ queryKey: ["media-usage"], queryFn: () => fetchUsage() });
71
+ const trashQ = useQuery({ queryKey: ["media-trash"], queryFn: () => fetchTrash() });
72
+ const rolesQ = useQuery({ queryKey: ["admin", "my-roles"], queryFn: () => fetchRoles() });
73
+
74
+ const metaMap = media.useMetaMap();
75
+
76
+ const refreshAll = async () => {
77
+ await Promise.all([
78
+ queryClient.invalidateQueries({ queryKey: ["media-library"] }),
79
+ queryClient.invalidateQueries({ queryKey: ["media-trash"] }),
80
+ queryClient.invalidateQueries({ queryKey: ["media-usage"] }),
81
+ ]);
82
+ };
83
+
84
+ const deps = useMemo<MediaLibraryDeps>(
85
+ () => ({
86
+ items: (items ?? []) as MediaLibraryDeps["items"],
87
+ isLoading,
88
+ trash: (trashQ.data ?? []) as MediaLibraryDeps["trash"],
89
+ trashLoading: trashQ.isLoading,
90
+ usage: usageQ.data as MediaLibraryDeps["usage"],
91
+ isSuperadmin: ((rolesQ.data ?? []) as string[]).includes("superadmin"),
92
+ metaMap,
93
+ deleteMedia: (input) => del(input as never),
94
+ restoreMedia: (input) => restore(input as never),
95
+ purgeMedia: (input) => purge(input as never),
96
+ emptyTrash: () => empty() as Promise<{ purged: number }>,
97
+ uploadMediaBundle: (input) => uploadBundle(input as never),
98
+ importUrlToMedia: (input) => runImport(input as never),
99
+ refreshAll,
100
+ invalidateLibrary: () => queryClient.invalidateQueries({ queryKey: ["media-library"] }),
101
+ FocalImageComponent: media.components.FocalImage,
102
+ MediaMetaDialogComponent: media.components.MediaMetaDialog,
103
+ FocusPickerComponent: media.components.FocusPicker,
104
+ MediaReplaceDialogComponent: media.components.MediaReplaceDialog,
105
+ maintenanceTools: media.maintenanceTools,
106
+ LinkComponent: HostLink as MediaLibraryDeps["LinkComponent"],
107
+ }),
108
+ // eslint-disable-next-line react-hooks/exhaustive-deps
109
+ [items, isLoading, trashQ.data, trashQ.isLoading, usageQ.data, rolesQ.data, metaMap],
110
+ );
111
+
112
+ return <MediaLibraryView deps={deps} />;
113
+ }
114
+
115
+ /** Forms index: list, create, duplicate, delete and the trash bin. */
116
+ export function FormsIndexRoute() {
117
+ const forms = requireHostGroup("forms");
118
+ const { fns } = backofficeHost();
119
+ const qc = useQueryClient();
120
+
121
+ const list = useServerFn(fns.listForms);
122
+ const create = useServerFn(forms.fns.createFormFromType);
123
+ const remove = useServerFn(forms.fns.deleteForm);
124
+ const copy = useServerFn(forms.fns.duplicateForm);
125
+ const usage = useServerFn(forms.fns.getFormUsage);
126
+ const listBin = useServerFn(forms.fns.listDeletedForms);
127
+ const restore = useServerFn(forms.fns.restoreForm);
128
+ const purge = useServerFn(forms.fns.purgeForm);
129
+
130
+ const { data: rows, isLoading } = useQuery({
131
+ queryKey: ["backoffice-forms"],
132
+ queryFn: () => list(),
133
+ });
134
+ const { data: bin } = useQuery({ queryKey: ["backoffice-forms-bin"], queryFn: () => listBin() });
135
+
136
+ const refresh = async () => {
137
+ await qc.invalidateQueries({ queryKey: ["backoffice-forms"] });
138
+ await qc.invalidateQueries({ queryKey: ["backoffice-forms-bin"] });
139
+ };
140
+
141
+ return (
142
+ <FormsIndexView
143
+ deps={{
144
+ forms: (rows ?? []) as never,
145
+ isLoading,
146
+ bin: bin as never,
147
+ createForm: (input) => create(input as never),
148
+ duplicateForm: (input) => copy(input as never),
149
+ deleteForm: (input) => remove(input as never),
150
+ restoreForm: (input) => restore(input as never),
151
+ purgeForm: (input) => purge(input as never),
152
+ getFormUsage: (input) => usage(input as never),
153
+ refresh,
154
+ LinkComponent: HostLink as never,
155
+ }}
156
+ />
157
+ );
158
+ }
159
+
160
+ /** Form editor for a single form key. */
161
+ export function FormEditorRoute() {
162
+ const forms = requireHostGroup("forms");
163
+ const { fns } = backofficeHost();
164
+ const params = useParams({ strict: false }) as { key?: string };
165
+ const key = params.key ?? "";
166
+
167
+ const list = useServerFn(fns.listForms);
168
+ const save = useServerFn(forms.fns.saveForm);
169
+ const sync = useServerFn(forms.fns.syncSubmissionsToSheet);
170
+ const dup = useServerFn(forms.fns.duplicateForm);
171
+ const pingWebhook = useServerFn(forms.fns.testWebhook);
172
+ const loadDefaults = useServerFn(fns.getFormDefaults);
173
+
174
+ const { data: rows, isLoading } = useQuery({
175
+ queryKey: ["backoffice-forms"],
176
+ queryFn: () => list(),
177
+ });
178
+ const { data: defaults } = useQuery({
179
+ queryKey: ["form-defaults"],
180
+ queryFn: () => loadDefaults(),
181
+ });
182
+ const form = useMemo(
183
+ () => ((rows ?? []) as { key: string }[]).find((f) => f.key === key),
184
+ [rows, key],
185
+ );
186
+
187
+ const C = forms.components;
188
+ return (
189
+ <FormEditorView
190
+ deps={{
191
+ form: form as never,
192
+ defaults: defaults as never,
193
+ isLoading,
194
+ saveForm: (input) => save(input as never),
195
+ duplicateForm: (input) => dup(input as never),
196
+ syncSubmissions: (input) => sync(input as never),
197
+ testWebhook: (input) => pingWebhook(input as never),
198
+ LinkComponent: HostLink as never,
199
+ FieldEditorComponent: C.FieldEditor,
200
+ FormPreviewComponent: C.FormPreview,
201
+ TestDeliveryPanelComponent: C.TestDeliveryPanel,
202
+ VersionHistoryComponent: C.VersionHistory,
203
+ ActivityLogComponent: C.ActivityLog,
204
+ }}
205
+ />
206
+ );
207
+ }
208
+
209
+ type SubmissionSearch = {
210
+ form?: string;
211
+ status?: string;
212
+ locale?: string;
213
+ q?: string;
214
+ from?: string;
215
+ to?: string;
216
+ page?: number;
217
+ };
218
+
219
+ /** Validator a site route reuses for the submissions screen search params. */
220
+ export function validateSubmissionSearch(search: Record<string, unknown>): SubmissionSearch {
221
+ const str = (v: unknown) => (typeof v === "string" && v ? v : undefined);
222
+ return {
223
+ form: str(search.form),
224
+ status: str(search.status),
225
+ locale: str(search.locale),
226
+ q: str(search.q),
227
+ from: str(search.from),
228
+ to: str(search.to),
229
+ page: typeof search.page === "number" ? search.page : undefined,
230
+ };
231
+ }
232
+
233
+ /** Submission inbox with filters, paging, exports and delivery retries. */
234
+ export function FormSubmissionsRoute() {
235
+ const forms = requireHostGroup("forms");
236
+ const host = backofficeHost();
237
+ const paths = backofficePaths(host);
238
+ const search = useSearch({ strict: false }) as SubmissionSearch;
239
+ const navigate = useNavigate();
240
+ const qc = useQueryClient();
241
+
242
+ const listF = useServerFn(host.fns.listForms);
243
+ const listS = useServerFn(forms.fns.listSubmissions);
244
+ const setStatus = useServerFn(forms.fns.setSubmissionStatus);
245
+ const push = useServerFn(forms.fns.pushSubmissionsToSheet);
246
+ const resend = useServerFn(forms.fns.resendDelivery);
247
+ const partials = useServerFn(forms.fns.listPartials);
248
+ const removePartials = useServerFn(forms.fns.deletePartials);
249
+
250
+ const page = search.page ?? 0;
251
+ const go = (next: SubmissionSearch) => navigate({ search: next as never });
252
+
253
+ const { data: rows } = useQuery({ queryKey: ["backoffice-forms"], queryFn: () => listF() });
254
+ const { data, isLoading, isFetching } = useQuery({
255
+ queryKey: ["form-submissions", search],
256
+ queryFn: () =>
257
+ listS({
258
+ data: {
259
+ formKey: search.form,
260
+ status: search.status,
261
+ locale: search.locale,
262
+ search: search.q,
263
+ from: search.from,
264
+ to: search.to,
265
+ page,
266
+ pageSize: SUBMISSIONS_PAGE_SIZE,
267
+ sort: "newest",
268
+ },
269
+ }),
270
+ });
271
+
272
+ return (
273
+ <FormSubmissionsView
274
+ deps={{
275
+ filters: search as never,
276
+ setFilters: (patch) => go({ ...search, ...(patch as SubmissionSearch), page: 0 }),
277
+ clearFilters: () => go({}),
278
+ goToPage: (next) => go({ ...search, page: next }),
279
+ data: data as never,
280
+ isLoading,
281
+ isFetching,
282
+ forms: ((rows ?? []) as { key: string; name: unknown; fields: unknown }[]).map((f) => ({
283
+ key: f.key,
284
+ name: f.name,
285
+ fields: f.fields,
286
+ })) as never,
287
+ locales: host.locales as never,
288
+ refresh: () => qc.invalidateQueries({ queryKey: ["form-submissions"] }),
289
+ setStatus: (input) => setStatus(input as never),
290
+ pushToSheet: (input) => push(input as never),
291
+ resendDelivery: (input) => resend(input as never),
292
+ listPartials: (input) => partials(input as never),
293
+ deletePartials: (input) => removePartials(input as never),
294
+ BackLink: ({ className, children }) => (
295
+ <HostLink to={paths.forms} className={className}>
296
+ {children}
297
+ </HostLink>
298
+ ),
299
+ }}
300
+ />
301
+ );
302
+ }
303
+
304
+ /** Delivery attempt dashboard (retry / resolve failed notifications). */
305
+ export function FormDeliveriesRoute() {
306
+ const forms = requireHostGroup("forms");
307
+ const host = backofficeHost();
308
+ const paths = backofficePaths(host);
309
+
310
+ const list = useServerFn(forms.fns.listDeliveryAttempts);
311
+ const formsFn = useServerFn(host.fns.listForms);
312
+ const retry = useServerFn(forms.fns.retryDeliveryAttempts);
313
+ const resolve = useServerFn(forms.fns.resolveDeliveryAttempts);
314
+
315
+ const [filters, setFiltersState] = useState<{
316
+ formKey: string;
317
+ channel: "" | DeliveryChannel;
318
+ state: "open" | "resolved" | "all";
319
+ }>({ formKey: "", channel: "", state: "open" });
320
+
321
+ const { data: formList } = useQuery({
322
+ queryKey: ["backoffice-forms"],
323
+ queryFn: () => formsFn(),
324
+ });
325
+ const { data, isLoading, refetch } = useQuery({
326
+ queryKey: ["form-deliveries", filters],
327
+ queryFn: () =>
328
+ list({
329
+ data: {
330
+ formKey: filters.formKey || undefined,
331
+ channel: filters.channel || undefined,
332
+ state: filters.state,
333
+ limit: 100,
334
+ },
335
+ }),
336
+ });
337
+
338
+ return (
339
+ <FormDeliveriesView
340
+ deps={{
341
+ data: data as never,
342
+ isLoading,
343
+ forms: ((formList ?? []) as { key: string; name: unknown }[]).map((f) => ({
344
+ key: f.key,
345
+ name: f.name,
346
+ })) as never,
347
+ filters,
348
+ setFilters: (patch) => setFiltersState((prev) => ({ ...prev, ...patch })),
349
+ refetch: () => refetch(),
350
+ retryAttempts: (input) => retry(input as never),
351
+ resolveAttempts: (input) => resolve(input as never),
352
+ BackLink: ({ className, children }) => (
353
+ <HostLink to={paths.forms} className={className}>
354
+ {children}
355
+ </HostLink>
356
+ ),
357
+ }}
358
+ />
359
+ );
360
+ }