@riducms/admin 0.1.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.
Files changed (226) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +31 -0
  3. package/THIRD_PARTY_NOTICES.md +217 -0
  4. package/package.json +62 -0
  5. package/src/app/admin-core-view-route.svelte +89 -0
  6. package/src/app/admin-layout.svelte +457 -0
  7. package/src/app/admin-navigation-progress.svelte +204 -0
  8. package/src/app/admin-page-title.svelte +94 -0
  9. package/src/app/admin-provider-layer.svelte +26 -0
  10. package/src/app/admin-router-root.svelte +9 -0
  11. package/src/app/admin-routes.svelte +116 -0
  12. package/src/app/deferred-redirect.svelte +23 -0
  13. package/src/app/loading-boundary.svelte +70 -0
  14. package/src/app/not-found-route.svelte +29 -0
  15. package/src/app-meta.ts +2 -0
  16. package/src/app.css +467 -0
  17. package/src/app.svelte +88 -0
  18. package/src/assets/ridu-logo-arch.svg +3 -0
  19. package/src/assets/ridu-word-logo.svg +7 -0
  20. package/src/components/brand/ridu-logo.svelte +24 -0
  21. package/src/components/code-block/code-block.svelte +107 -0
  22. package/src/components/json-tree/json-tree-node.svelte +77 -0
  23. package/src/components/json-tree/json-viewer.svelte +113 -0
  24. package/src/components/ui/banner/banner.svelte +41 -0
  25. package/src/components/ui/banner/index.ts +5 -0
  26. package/src/components/ui/bulk-action-bar/bulk-action-bar.svelte +50 -0
  27. package/src/components/ui/bulk-action-bar/index.ts +1 -0
  28. package/src/components/ui/button/index.ts +8 -0
  29. package/src/components/ui/calendar/calendar-caption.svelte +72 -0
  30. package/src/components/ui/calendar/calendar-cell.svelte +20 -0
  31. package/src/components/ui/calendar/calendar-day.svelte +27 -0
  32. package/src/components/ui/calendar/calendar-grid-row.svelte +13 -0
  33. package/src/components/ui/calendar/calendar-grid.svelte +17 -0
  34. package/src/components/ui/calendar/calendar-head-cell.svelte +20 -0
  35. package/src/components/ui/calendar/calendar-header.svelte +20 -0
  36. package/src/components/ui/calendar/calendar-heading.svelte +17 -0
  37. package/src/components/ui/calendar/calendar-month-select.svelte +45 -0
  38. package/src/components/ui/calendar/calendar-month.svelte +16 -0
  39. package/src/components/ui/calendar/calendar-months.svelte +20 -0
  40. package/src/components/ui/calendar/calendar-nav.svelte +20 -0
  41. package/src/components/ui/calendar/calendar-next-button.svelte +30 -0
  42. package/src/components/ui/calendar/calendar-prev-button.svelte +30 -0
  43. package/src/components/ui/calendar/calendar-year-select.svelte +44 -0
  44. package/src/components/ui/calendar/calendar.svelte +118 -0
  45. package/src/components/ui/calendar/index.ts +1 -0
  46. package/src/components/ui/checkbox/checkbox.svelte +35 -0
  47. package/src/components/ui/checkbox/index.ts +1 -0
  48. package/src/components/ui/confirmation-dialog/confirmation-dialog.svelte +37 -0
  49. package/src/components/ui/confirmation-dialog/index.ts +1 -0
  50. package/src/components/ui/date-value-control/date-value-calendar.svelte +37 -0
  51. package/src/components/ui/date-value-control/date-value-control-loader.svelte +36 -0
  52. package/src/components/ui/date-value-control/date-value-control.svelte +177 -0
  53. package/src/components/ui/date-value-control/index.ts +3 -0
  54. package/src/components/ui/dialog/dialog-close.svelte +11 -0
  55. package/src/components/ui/dialog/dialog-content.svelte +52 -0
  56. package/src/components/ui/dialog/dialog-description.svelte +20 -0
  57. package/src/components/ui/dialog/dialog-footer.svelte +20 -0
  58. package/src/components/ui/dialog/dialog-header.svelte +20 -0
  59. package/src/components/ui/dialog/dialog-overlay.svelte +20 -0
  60. package/src/components/ui/dialog/dialog-title.svelte +17 -0
  61. package/src/components/ui/dialog/dialog-trigger.svelte +11 -0
  62. package/src/components/ui/dialog/dialog.svelte +7 -0
  63. package/src/components/ui/dialog/index.ts +8 -0
  64. package/src/components/ui/dropdown-menu/dropdown-menu-content.svelte +27 -0
  65. package/src/components/ui/dropdown-menu/dropdown-menu-item.svelte +20 -0
  66. package/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte +17 -0
  67. package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +16 -0
  68. package/src/components/ui/dropdown-menu/dropdown-menu.svelte +7 -0
  69. package/src/components/ui/dropdown-menu/index.ts +5 -0
  70. package/src/components/ui/empty-state/empty-state.svelte +34 -0
  71. package/src/components/ui/empty-state/index.ts +1 -0
  72. package/src/components/ui/input/index.ts +1 -0
  73. package/src/components/ui/kbd/index.ts +1 -0
  74. package/src/components/ui/kbd/kbd.svelte +24 -0
  75. package/src/components/ui/pagination/index.ts +1 -0
  76. package/src/components/ui/pagination/pagination.svelte +88 -0
  77. package/src/components/ui/popover/index.ts +3 -0
  78. package/src/components/ui/popover/popover-content.svelte +29 -0
  79. package/src/components/ui/popover/popover-trigger.svelte +11 -0
  80. package/src/components/ui/popover/popover.svelte +7 -0
  81. package/src/components/ui/radio-group/index.ts +3 -0
  82. package/src/components/ui/radio-group/radio-card-item.svelte +38 -0
  83. package/src/components/ui/radio-group/radio-group-item.svelte +17 -0
  84. package/src/components/ui/radio-group/radio-group.svelte +19 -0
  85. package/src/components/ui/select/index.ts +4 -0
  86. package/src/components/ui/select/select-content.svelte +38 -0
  87. package/src/components/ui/select/select-item.svelte +40 -0
  88. package/src/components/ui/select/select-scroll-down-button.svelte +23 -0
  89. package/src/components/ui/select/select-scroll-up-button.svelte +23 -0
  90. package/src/components/ui/select/select-trigger.svelte +29 -0
  91. package/src/components/ui/select/select.svelte +11 -0
  92. package/src/components/ui/sheet/index.ts +6 -0
  93. package/src/components/ui/sheet/sheet-close.svelte +7 -0
  94. package/src/components/ui/sheet/sheet-content.svelte +77 -0
  95. package/src/components/ui/sheet/sheet-description.svelte +17 -0
  96. package/src/components/ui/sheet/sheet-overlay.svelte +36 -0
  97. package/src/components/ui/sheet/sheet-title.svelte +17 -0
  98. package/src/components/ui/sheet/sheet-trigger.svelte +7 -0
  99. package/src/components/ui/sheet/sheet.svelte +7 -0
  100. package/src/components/ui/skeleton/index.ts +1 -0
  101. package/src/components/ui/skeleton/skeleton.svelte +31 -0
  102. package/src/components/ui/slider/index.ts +3 -0
  103. package/src/components/ui/slider/slider-content.svelte +59 -0
  104. package/src/components/ui/slider/slider.svelte +35 -0
  105. package/src/components/ui/sonner/index.ts +1 -0
  106. package/src/components/ui/sonner/sonner.svelte +55 -0
  107. package/src/components/ui/status-indicator/index.ts +5 -0
  108. package/src/components/ui/status-indicator/status-indicator.svelte +54 -0
  109. package/src/components/ui/table/index.ts +6 -0
  110. package/src/components/ui/table/table-body.svelte +20 -0
  111. package/src/components/ui/table/table-cell.svelte +23 -0
  112. package/src/components/ui/table/table-head.svelte +23 -0
  113. package/src/components/ui/table/table-header.svelte +20 -0
  114. package/src/components/ui/table/table-row.svelte +23 -0
  115. package/src/components/ui/table/table.svelte +22 -0
  116. package/src/components/ui/tabs/index.ts +8 -0
  117. package/src/components/ui/tabs/tabs-content.svelte +17 -0
  118. package/src/components/ui/tabs/tabs-list.svelte +40 -0
  119. package/src/components/ui/tabs/tabs-trigger.svelte +23 -0
  120. package/src/components/ui/tabs/tabs.svelte +19 -0
  121. package/src/components/ui/textarea/index.ts +1 -0
  122. package/src/components/ui/tooltip/index.ts +4 -0
  123. package/src/components/ui/tooltip/tooltip-content.svelte +50 -0
  124. package/src/components/ui/tooltip/tooltip-provider.svelte +7 -0
  125. package/src/components/ui/tooltip/tooltip-trigger.svelte +7 -0
  126. package/src/components/ui/tooltip/tooltip.svelte +7 -0
  127. package/src/core/api/admin-client.ts +45 -0
  128. package/src/core/forms/field-condition.ts +63 -0
  129. package/src/core/forms/form-controller.svelte.ts +369 -0
  130. package/src/core/forms/form-draft-recovery.ts +80 -0
  131. package/src/core/forms/form-schema.ts +609 -0
  132. package/src/core/forms/form-validation.ts +750 -0
  133. package/src/core/i18n/admin-i18n.svelte.ts +364 -0
  134. package/src/core/i18n/localized-schema.ts +111 -0
  135. package/src/core/notifications/notification-center.svelte.ts +69 -0
  136. package/src/core/notifications/validation-toast.svelte +18 -0
  137. package/src/core/plugins/field-registry.ts +60 -0
  138. package/src/core/plugins/row-label-registry.ts +62 -0
  139. package/src/core/preferences/preference-write-queue.ts +107 -0
  140. package/src/core/routing/admin-paths.ts +137 -0
  141. package/src/core/runtime/admin-runtime.svelte.ts +625 -0
  142. package/src/features/auth/account-menu.svelte +159 -0
  143. package/src/features/auth/account-navigation.svelte +44 -0
  144. package/src/features/auth/account-route.svelte +395 -0
  145. package/src/features/auth/account-security-controller.svelte.ts +119 -0
  146. package/src/features/auth/account-security-route.svelte +460 -0
  147. package/src/features/auth/auth-brand.svelte +17 -0
  148. package/src/features/auth/auth-frame.svelte +18 -0
  149. package/src/features/auth/create-first-user-controller.svelte.ts +220 -0
  150. package/src/features/auth/create-first-user-route.svelte +174 -0
  151. package/src/features/auth/forgot-password-route.svelte +78 -0
  152. package/src/features/auth/login-route.svelte +221 -0
  153. package/src/features/auth/request-verification-route.svelte +73 -0
  154. package/src/features/auth/reset-password-route.svelte +107 -0
  155. package/src/features/auth/verify-email-route.svelte +60 -0
  156. package/src/features/collections/collection-list-bulk-actions.svelte +143 -0
  157. package/src/features/collections/collection-list-bulk-editor.svelte +157 -0
  158. package/src/features/collections/collection-list-controller.svelte.ts +717 -0
  159. package/src/features/collections/collection-list-filter-popover.svelte +238 -0
  160. package/src/features/collections/collection-list-preferences-controller.svelte.ts +393 -0
  161. package/src/features/collections/collection-list-results.svelte +410 -0
  162. package/src/features/collections/collection-list-route.svelte +951 -0
  163. package/src/features/collections/collection-list-workspace-toolbar.svelte +409 -0
  164. package/src/features/collections/collection-table-skeleton.svelte +44 -0
  165. package/src/features/collections/list-workspace.ts +265 -0
  166. package/src/features/dashboard/dashboard-route.svelte +141 -0
  167. package/src/features/development/development-tools.svelte +217 -0
  168. package/src/features/documents/document-api-view-loader.svelte +31 -0
  169. package/src/features/documents/document-api-view.svelte +547 -0
  170. package/src/features/documents/document-controller.svelte.ts +1252 -0
  171. package/src/features/documents/document-field-partition.ts +18 -0
  172. package/src/features/documents/document-field-sections.svelte +46 -0
  173. package/src/features/documents/document-heading.ts +20 -0
  174. package/src/features/documents/document-route-dirty.ts +43 -0
  175. package/src/features/documents/document-route-loader.svelte +22 -0
  176. package/src/features/documents/document-route.svelte +1124 -0
  177. package/src/features/documents/live-preview-panel.svelte +390 -0
  178. package/src/features/documents/live-preview.ts +61 -0
  179. package/src/features/documents/upload-document-preview-loader.svelte +40 -0
  180. package/src/features/documents/upload-document-preview.svelte +525 -0
  181. package/src/features/localization/content-locale-switcher.svelte +93 -0
  182. package/src/features/navigation/admin-breadcrumbs.svelte +124 -0
  183. package/src/features/navigation/admin-command-menu.svelte +245 -0
  184. package/src/features/navigation/admin-resource-groups.ts +72 -0
  185. package/src/features/reference-browser/reference-browser-loader.svelte +15 -0
  186. package/src/features/reference-browser/reference-browser-workflow.svelte.ts +691 -0
  187. package/src/features/reference-browser/reference-browser.svelte +844 -0
  188. package/src/features/uploads/bulk-upload-route.svelte +624 -0
  189. package/src/features/uploads/bulk-upload.ts +39 -0
  190. package/src/features/uploads/upload-metadata-fields.svelte +112 -0
  191. package/src/features/versions/version-diff.ts +72 -0
  192. package/src/features/versions/version-history-route.svelte +581 -0
  193. package/src/fields/field-clipboard.ts +132 -0
  194. package/src/fields/field-layout.svelte +60 -0
  195. package/src/fields/field-layout.ts +57 -0
  196. package/src/fields/field-messages.svelte +29 -0
  197. package/src/fields/field-renderer.svelte +152 -0
  198. package/src/fields/field-shell.svelte +30 -0
  199. package/src/fields/field-tab-layout.svelte +99 -0
  200. package/src/fields/join/join-field.svelte +358 -0
  201. package/src/fields/localized-field-presentation.ts +17 -0
  202. package/src/fields/nested/nested-field.svelte +602 -0
  203. package/src/fields/nested/scoped-field.ts +78 -0
  204. package/src/fields/nested/sortable-row.svelte +32 -0
  205. package/src/fields/point/point-field.svelte +52 -0
  206. package/src/fields/radio/radio-field.svelte +34 -0
  207. package/src/fields/relationship/relationship-field-controller.svelte.ts +245 -0
  208. package/src/fields/relationship/relationship-field.svelte +258 -0
  209. package/src/fields/relationship/relationship-lookup-controller.svelte.ts +129 -0
  210. package/src/fields/relationship/relationship-query.ts +88 -0
  211. package/src/fields/relationship/relationship-quick-picker-controller.svelte.ts +198 -0
  212. package/src/fields/relationship/relationship-quick-picker.svelte +327 -0
  213. package/src/fields/relationship/relationship-value.ts +47 -0
  214. package/src/fields/scalar/date-control-value.ts +60 -0
  215. package/src/fields/scalar/date-value.ts +26 -0
  216. package/src/fields/scalar/scalar-field.svelte +137 -0
  217. package/src/fields/select/select-field.svelte +132 -0
  218. package/src/fields/select/select-value.ts +14 -0
  219. package/src/fields/text/slug-field.svelte +87 -0
  220. package/src/fields/text/slug.ts +38 -0
  221. package/src/fields/text/text-field.svelte +41 -0
  222. package/src/fields/ui/ui-field.svelte +12 -0
  223. package/src/fields/virtual/virtual-field.svelte +28 -0
  224. package/src/index.ts +49 -0
  225. package/src/main.ts +12 -0
  226. package/src/vite-env.d.ts +9 -0
@@ -0,0 +1,691 @@
1
+ import type { SchemaCollection, SchemaField } from "@riducms/protocol";
2
+ import { RiduError } from "@riducms/sdk";
3
+
4
+ import type { AdminDocument } from "@admin/core/api/admin-client";
5
+ import { FormController, FormValidationError } from "@admin/core/forms/form-controller.svelte";
6
+ import { documentFormValues, initialFormValues } from "@admin/core/forms/form-schema";
7
+ import { invalidFieldLabels } from "@admin/core/forms/form-validation";
8
+ import type { NotificationCenter } from "@admin/core/notifications/notification-center.svelte";
9
+ import type { AdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
10
+
11
+ import { RelationshipLookupController } from "@admin/fields/relationship/relationship-lookup-controller.svelte";
12
+ import {
13
+ combineRelationshipFilters,
14
+ type RelationshipFilter,
15
+ } from "@admin/fields/relationship/relationship-query";
16
+
17
+ interface FilterOption {
18
+ key: string;
19
+ label: string;
20
+ filter: RelationshipFilter;
21
+ }
22
+
23
+ interface ReferenceBrowserWorkflowOptions {
24
+ runtime: AdminRuntime;
25
+ notifications: NotificationCenter;
26
+ field: SchemaField;
27
+ collection: SchemaCollection;
28
+ hasMany: boolean;
29
+ selectedIDs: readonly string[];
30
+ readOnly: boolean;
31
+ initialDocument?: AdminDocument;
32
+ initialDocumentID?: string;
33
+ optionFilter?: RelationshipFilter;
34
+ defaultValues?: Readonly<Record<string, unknown>>;
35
+ allowCreate?: boolean;
36
+ locale?: string;
37
+ onCommit: (ids: string[]) => void | boolean | Promise<void | boolean>;
38
+ onClose: () => void;
39
+ get open(): boolean;
40
+ setOpen(open: boolean): void;
41
+ }
42
+
43
+ const uploadMetadataFields = new Set([
44
+ "filename",
45
+ "mimeType",
46
+ "filesize",
47
+ "url",
48
+ "objectKey",
49
+ "width",
50
+ "height",
51
+ "sizes",
52
+ "focalX",
53
+ "focalY",
54
+ "cropX",
55
+ "cropY",
56
+ "cropWidth",
57
+ "cropHeight",
58
+ ]);
59
+
60
+ export class ReferenceBrowserWorkflow {
61
+ readonly form: FormController;
62
+ readonly pageSize: number;
63
+ readonly scopedFields: SchemaField[];
64
+ readonly displayField: SchemaField | undefined;
65
+ readonly headingField: SchemaField | undefined;
66
+ readonly searchField: SchemaField | undefined;
67
+ readonly editorFields: SchemaField[];
68
+ readonly validationFields: SchemaField[];
69
+ readonly filters: FilterOption[];
70
+ screen = $state<"list" | "document">("list");
71
+ workingSelection = $state.raw<string[]>([]);
72
+ query = $state("");
73
+ page = $state(1);
74
+ filterKey = $state("");
75
+ editorDocument = $state.raw<AdminDocument>();
76
+ editorLoading = $state(false);
77
+ editorError = $state<string>();
78
+ editorView = $state<"edit" | "api">("edit");
79
+ selectedFiles = $state<FileList>();
80
+ newUserPassword = $state("");
81
+ newUserPasswordConfirmation = $state("");
82
+ credentialIssue = $state<string>();
83
+ confirmDiscard = $state(false);
84
+ committing = $state(false);
85
+ #pendingDismiss: "back" | "close" = "back";
86
+ readonly #lookup: RelationshipLookupController;
87
+ readonly #editorLoad = new AbortController();
88
+ #editorAccess?: AbortController;
89
+ #saveRequest?: AbortController;
90
+
91
+ constructor(readonly options: ReferenceBrowserWorkflowOptions) {
92
+ this.form = new FormController({}, options.runtime.i18n);
93
+ this.#lookup = new RelationshipLookupController(options.runtime.client);
94
+ this.form.setLocalization(options.locale);
95
+ this.pageSize = options.collection.capabilities.upload ? 8 : 10;
96
+ this.scopedFields = options.collection.fields.map((field) =>
97
+ scopeField(field, `${options.field.id}-drawer`)
98
+ );
99
+ this.displayField = findDisplayField(this.scopedFields);
100
+ this.headingField = this.displayField?.admin.row === undefined ? this.displayField : undefined;
101
+ this.searchField = findDisplayField(options.collection.fields);
102
+ this.editorFields = this.scopedFields.filter(
103
+ (field) => field.id !== this.headingField?.id && !uploadMetadataFields.has(field.name)
104
+ );
105
+ this.validationFields = options.collection.fields.filter(
106
+ (field) => !(options.collection.capabilities.upload && uploadMetadataFields.has(field.name))
107
+ );
108
+ this.filters = createCollectionFilters(options.collection, options.runtime.i18n);
109
+ this.workingSelection = [...options.selectedIDs];
110
+
111
+ if (options.initialDocument !== undefined) {
112
+ this.#lookup.remember(options.initialDocument);
113
+ this.#openEditor(options.initialDocument);
114
+ } else if (options.initialDocumentID !== undefined) {
115
+ this.screen = "document";
116
+ this.#loadEditor(options.initialDocumentID, this.#editorLoad.signal);
117
+ }
118
+
119
+ $effect(() => {
120
+ if (!this.options.open || this.screen !== "list") return;
121
+ this.options.runtime.documentRevision;
122
+ const request = {
123
+ slug: this.options.collection.slug,
124
+ page: this.page,
125
+ limit: this.pageSize,
126
+ search: this.query,
127
+ searchField: this.searchField?.name,
128
+ filter: this.currentFilter,
129
+ locale: this.options.locale,
130
+ };
131
+ const delay = request.search.trim() === "" ? 0 : 220;
132
+ const timer = window.setTimeout(() => this.#lookup.search(request), delay);
133
+ return () => window.clearTimeout(timer);
134
+ });
135
+
136
+ $effect(() => () => {
137
+ this.#editorLoad.abort();
138
+ this.#editorAccess?.abort();
139
+ this.#saveRequest?.abort();
140
+ this.#lookup.dispose();
141
+ });
142
+ }
143
+
144
+ get status() {
145
+ return this.#lookup.status;
146
+ }
147
+
148
+ get docs() {
149
+ return this.#lookup.docs;
150
+ }
151
+
152
+ get pagination() {
153
+ return this.#lookup.pagination;
154
+ }
155
+
156
+ get error() {
157
+ return this.#lookup.error;
158
+ }
159
+
160
+ get selectedFile() {
161
+ return this.selectedFiles?.item(0) ?? undefined;
162
+ }
163
+
164
+ get creating() {
165
+ return this.screen === "document" && this.editorDocument === undefined;
166
+ }
167
+
168
+ get creatingAuthUser() {
169
+ return this.creating && this.options.collection.capabilities.auth;
170
+ }
171
+
172
+ get editorDirty() {
173
+ return (
174
+ this.form.dirty ||
175
+ this.selectedFile !== undefined ||
176
+ this.newUserPassword !== "" ||
177
+ this.newUserPasswordConfirmation !== ""
178
+ );
179
+ }
180
+
181
+ get currentFilter() {
182
+ return combineRelationshipFilters(
183
+ this.options.optionFilter,
184
+ this.filters.find((option) => option.key === this.filterKey)?.filter
185
+ );
186
+ }
187
+
188
+ get editorTitle() {
189
+ const value =
190
+ this.displayField === undefined ? undefined : this.form.get(this.displayField.path);
191
+ const title = typeof value === "string" ? value.trim() : "";
192
+ return title === ""
193
+ ? this.options.runtime.i18n.t("reference:untitledLabel", {
194
+ label: this.options.runtime.i18n
195
+ .text(
196
+ this.options.collection.labels.singular,
197
+ this.options.collection.labels.singularTranslations
198
+ )
199
+ .toLocaleLowerCase(this.options.runtime.i18n.language),
200
+ })
201
+ : title;
202
+ }
203
+
204
+ get headingIssues() {
205
+ return this.headingField === undefined ? [] : this.form.issuesFor(this.headingField.path);
206
+ }
207
+
208
+ get canSave() {
209
+ const operationAllowed = this.creating
210
+ ? this.form.access?.operations.create === true
211
+ : this.form.access?.operations.update === true;
212
+ const inputReady = this.creating
213
+ ? !this.options.collection.capabilities.upload || this.selectedFile !== undefined
214
+ : this.form.dirty;
215
+ return !this.options.readOnly && operationAllowed && !this.form.submitting && inputReady;
216
+ }
217
+
218
+ get canCreateDocument() {
219
+ return (
220
+ !this.options.readOnly &&
221
+ this.options.allowCreate !== false &&
222
+ this.options.runtime.collectionOperations[this.options.collection.slug]?.create === true
223
+ );
224
+ }
225
+
226
+ get canCommitSelection() {
227
+ return (
228
+ this.workingSelection.length !== this.options.selectedIDs.length ||
229
+ this.workingSelection.some((id) => !this.options.selectedIDs.includes(id))
230
+ );
231
+ }
232
+
233
+ get rangeStart() {
234
+ return this.pagination.totalDocs === 0
235
+ ? 0
236
+ : (this.pagination.page - 1) * this.pagination.limit + 1;
237
+ }
238
+
239
+ get rangeEnd() {
240
+ return Math.min(this.pagination.page * this.pagination.limit, this.pagination.totalDocs);
241
+ }
242
+
243
+ documentLabel = (document: AdminDocument) => {
244
+ const value = this.options.collection.capabilities.upload
245
+ ? document.filename
246
+ : this.searchField === undefined
247
+ ? undefined
248
+ : document[this.searchField.name];
249
+ return String(value ?? document.id);
250
+ };
251
+
252
+ documentSubtitle = (document: AdminDocument) => {
253
+ if (this.options.collection.capabilities.upload) {
254
+ return [document.mimeType, this.formatBytes(document.filesize)].filter(Boolean).join(" · ");
255
+ }
256
+ const email = typeof document.email === "string" ? document.email : undefined;
257
+ if (email !== undefined && email !== this.documentLabel(document)) return email;
258
+ const status = typeof document.status === "string" ? document.status : document._status;
259
+ return typeof status === "string"
260
+ ? humanize(status, this.options.runtime.i18n.language)
261
+ : document.id;
262
+ };
263
+
264
+ toggleSelection = (id: string) => {
265
+ if (this.options.readOnly || this.committing) return;
266
+ this.workingSelection = this.options.hasMany
267
+ ? this.workingSelection.includes(id)
268
+ ? this.workingSelection.filter((candidate) => candidate !== id)
269
+ : [...this.workingSelection, id]
270
+ : [id];
271
+ };
272
+
273
+ commitSelection = async () => {
274
+ if (this.committing) return;
275
+ this.committing = true;
276
+ try {
277
+ const committed = await this.options.onCommit([...this.workingSelection]);
278
+ if (committed !== false) this.#closeBrowser();
279
+ } catch (cause) {
280
+ this.options.notifications.error({
281
+ title: this.options.runtime.i18n.t("reference:updateNotConfirmed"),
282
+ message:
283
+ cause instanceof Error
284
+ ? cause.message
285
+ : this.options.runtime.i18n.t("reference:selectedNotSaved"),
286
+ });
287
+ } finally {
288
+ this.committing = false;
289
+ }
290
+ };
291
+
292
+ chooseFilter = (key: string) => {
293
+ if (this.committing) return;
294
+ this.filterKey = key;
295
+ this.page = 1;
296
+ };
297
+
298
+ handleSearch = (event: Event) => {
299
+ if (this.committing) return;
300
+ this.query = (event.currentTarget as HTMLInputElement).value;
301
+ this.page = 1;
302
+ };
303
+
304
+ openNewDocument = () => {
305
+ if (this.form.submitting || !this.canCreateDocument) return;
306
+ this.editorDocument = undefined;
307
+ const values = {
308
+ ...initialFormValues(this.options.collection.fields),
309
+ ...this.options.defaultValues,
310
+ };
311
+ this.form.reset(values);
312
+ this.form.setAccess(undefined, "create");
313
+ this.selectedFiles = undefined;
314
+ this.newUserPassword = "";
315
+ this.newUserPasswordConfirmation = "";
316
+ this.credentialIssue = undefined;
317
+ this.editorError = undefined;
318
+ this.editorView = "edit";
319
+ this.screen = "document";
320
+ this.#loadEditorAccess(undefined, values);
321
+ };
322
+
323
+ openKnownDocument = (document: AdminDocument) => {
324
+ this.#lookup.remember(document);
325
+ this.#openEditor(document);
326
+ };
327
+
328
+ saveEditor = async () => {
329
+ if (this.form.submitting || !this.canSave) return false;
330
+ const creatingUpload =
331
+ this.creating &&
332
+ this.options.collection.capabilities.upload &&
333
+ this.selectedFile !== undefined;
334
+ const request = new AbortController();
335
+ this.#saveRequest?.abort();
336
+ this.#saveRequest = request;
337
+ try {
338
+ this.credentialIssue = this.#validateNewUserPassword();
339
+ if (this.credentialIssue !== undefined) return false;
340
+ const saved = await this.form.submit(this.validationFields, this.creating, async (values) => {
341
+ if (this.editorDocument !== undefined) {
342
+ return this.options.runtime.client.update(
343
+ this.options.collection.slug,
344
+ this.editorDocument.id,
345
+ values,
346
+ {
347
+ revision:
348
+ typeof this.editorDocument._revision === "number"
349
+ ? this.editorDocument._revision
350
+ : undefined,
351
+ signal: request.signal,
352
+ locale: this.options.locale,
353
+ }
354
+ );
355
+ }
356
+ if (this.options.collection.capabilities.auth) {
357
+ return this.options.runtime.client.createAuthUser(
358
+ this.options.collection.slug,
359
+ values,
360
+ this.newUserPassword,
361
+ { signal: request.signal, locale: this.options.locale }
362
+ );
363
+ }
364
+ if (!this.options.collection.capabilities.upload) {
365
+ return this.options.runtime.client.create(this.options.collection.slug, values, {
366
+ signal: request.signal,
367
+ locale: this.options.locale,
368
+ });
369
+ }
370
+ if (this.selectedFile === undefined) {
371
+ throw new Error(this.options.runtime.i18n.t("uploads:chooseFileBeforeCreating"));
372
+ }
373
+ return this.options.runtime.client.upload(this.options.collection.slug, this.selectedFile, {
374
+ data: values,
375
+ signal: request.signal,
376
+ locale: this.options.locale,
377
+ });
378
+ });
379
+ if (request.signal.aborted) return false;
380
+ const wasCreating = this.editorDocument === undefined;
381
+ this.editorDocument = saved;
382
+ this.#lookup.remember(saved);
383
+ this.workingSelection = this.options.hasMany
384
+ ? [...new Set([...this.workingSelection, saved.id])]
385
+ : [saved.id];
386
+ this.options.runtime.documentsChanged();
387
+ this.form.reset(documentFormValues(this.options.collection.fields, saved));
388
+ this.form.setLocalization(this.options.locale, saved._localization?.sources);
389
+ this.options.notifications.success({
390
+ title: wasCreating
391
+ ? this.options.runtime.i18n.t("reference:createdLabel", {
392
+ label: this.options.runtime.i18n.text(
393
+ this.options.collection.labels.singular,
394
+ this.options.collection.labels.singularTranslations
395
+ ),
396
+ })
397
+ : this.options.runtime.i18n.t("documents:updated"),
398
+ });
399
+ if (wasCreating) this.screen = "list";
400
+ this.newUserPassword = "";
401
+ this.newUserPasswordConfirmation = "";
402
+ this.credentialIssue = undefined;
403
+ return true;
404
+ } catch (cause) {
405
+ if (request.signal.aborted) return false;
406
+ if (
407
+ creatingUpload &&
408
+ !(cause instanceof FormValidationError) &&
409
+ (!(cause instanceof RiduError) || cause.status >= 500)
410
+ ) {
411
+ this.options.notifications.error({
412
+ title: this.options.runtime.i18n.t("uploads:outcomeUnknown"),
413
+ message: this.options.runtime.i18n.t("uploads:outcomeUnknownListRefreshed"),
414
+ });
415
+ this.#returnToList();
416
+ this.options.runtime.documentsChanged();
417
+ return false;
418
+ }
419
+ const validationFailure =
420
+ this.form.issues.length > 0 &&
421
+ (cause instanceof FormValidationError ||
422
+ (cause instanceof RiduError && cause.code === "validation"));
423
+ if (validationFailure) {
424
+ this.options.notifications.validation({
425
+ labels: invalidFieldLabels(
426
+ this.validationFields,
427
+ this.form.issues,
428
+ this.options.runtime.i18n
429
+ ),
430
+ });
431
+ } else {
432
+ this.options.notifications.error({
433
+ title: this.options.runtime.i18n.t("reference:relatedNotSaved"),
434
+ message:
435
+ cause instanceof Error
436
+ ? cause.message
437
+ : this.options.runtime.i18n.t("reference:relatedSaveFailed"),
438
+ });
439
+ }
440
+ return false;
441
+ } finally {
442
+ if (this.#saveRequest === request) this.#saveRequest = undefined;
443
+ }
444
+ };
445
+
446
+ #validateNewUserPassword() {
447
+ if (!this.creatingAuthUser || this.options.collection.authSettings === undefined) {
448
+ return undefined;
449
+ }
450
+ if (this.newUserPassword === "")
451
+ return this.options.runtime.i18n.t("reference:enterNewAccountPassword");
452
+ if (
453
+ Array.from(this.newUserPassword).length <
454
+ this.options.collection.authSettings.passwordMinLength
455
+ ) {
456
+ return this.options.runtime.i18n.t("reference:passwordTooShort", {
457
+ minimum: this.options.runtime.i18n.formatNumber(
458
+ this.options.collection.authSettings.passwordMinLength
459
+ ),
460
+ });
461
+ }
462
+ if (
463
+ new TextEncoder().encode(this.newUserPassword).length >
464
+ this.options.collection.authSettings.passwordMaxBytes
465
+ ) {
466
+ return this.options.runtime.i18n.t("reference:passwordTooLong", {
467
+ maximum: this.options.runtime.i18n.formatNumber(
468
+ this.options.collection.authSettings.passwordMaxBytes
469
+ ),
470
+ });
471
+ }
472
+ if (this.newUserPassword !== this.newUserPasswordConfirmation) {
473
+ return this.options.runtime.i18n.t("reference:passwordsDoNotMatch");
474
+ }
475
+ return undefined;
476
+ }
477
+
478
+ requestBack = () => {
479
+ if (this.form.submitting) return;
480
+ if (this.editorDirty) {
481
+ this.#pendingDismiss = "back";
482
+ this.confirmDiscard = true;
483
+ return;
484
+ }
485
+ this.#returnToList();
486
+ };
487
+
488
+ requestOpenChange = (open: boolean) => {
489
+ if (open) {
490
+ this.options.setOpen(true);
491
+ return;
492
+ }
493
+ if (this.committing || this.form.submitting) return;
494
+ if (this.screen === "document" && this.editorDirty) {
495
+ this.#pendingDismiss = "close";
496
+ this.confirmDiscard = true;
497
+ return;
498
+ }
499
+ this.#closeBrowser();
500
+ };
501
+
502
+ discardChanges = () => {
503
+ if (this.form.submitting) return;
504
+ this.confirmDiscard = false;
505
+ if (this.#pendingDismiss === "close") {
506
+ this.#closeBrowser();
507
+ return;
508
+ }
509
+ this.#returnToList();
510
+ };
511
+
512
+ mediaURL = (document: AdminDocument | undefined) =>
513
+ typeof document?.url === "string" ? document.url : undefined;
514
+
515
+ isImage = (document: AdminDocument | undefined) =>
516
+ typeof document?.mimeType === "string" && document.mimeType.startsWith("image/");
517
+
518
+ formatBytes = (value: unknown) => {
519
+ if (typeof value !== "number" || !Number.isFinite(value)) return "";
520
+ if (value < 1_024)
521
+ return this.options.runtime.i18n.formatNumber(value, { style: "unit", unit: "byte" });
522
+ if (value < 1_048_576)
523
+ return this.options.runtime.i18n.formatNumber(value / 1_024, {
524
+ maximumFractionDigits: 1,
525
+ style: "unit",
526
+ unit: "kilobyte",
527
+ });
528
+ return this.options.runtime.i18n.formatNumber(value / 1_048_576, {
529
+ maximumFractionDigits: 1,
530
+ style: "unit",
531
+ unit: "megabyte",
532
+ });
533
+ };
534
+
535
+ #loadEditor(id: string, signal?: AbortSignal) {
536
+ this.editorLoading = true;
537
+ this.editorError = undefined;
538
+ this.#lookup
539
+ .loadDocument(this.options.collection.slug, id, this.options.locale, signal)
540
+ .then((document) => {
541
+ if (signal?.aborted) return;
542
+ if (document === undefined) {
543
+ this.editorError =
544
+ this.#lookup.error ?? this.options.runtime.i18n.t("reference:relatedLoadFailed");
545
+ this.editorLoading = false;
546
+ return;
547
+ }
548
+ this.#openEditor(document);
549
+ });
550
+ }
551
+
552
+ #openEditor(document: AdminDocument) {
553
+ this.editorDocument = document;
554
+ this.form.reset(documentFormValues(this.options.collection.fields, document));
555
+ this.form.setLocalization(this.options.locale, document._localization?.sources);
556
+ this.form.setAccess(undefined, "update");
557
+ this.selectedFiles = undefined;
558
+ this.newUserPassword = "";
559
+ this.newUserPasswordConfirmation = "";
560
+ this.credentialIssue = undefined;
561
+ this.editorError = undefined;
562
+ this.editorView = "edit";
563
+ this.screen = "document";
564
+ this.#loadEditorAccess(document.id);
565
+ }
566
+
567
+ #loadEditorAccess(id: string | undefined, data?: Record<string, unknown>) {
568
+ this.#editorAccess?.abort();
569
+ const request = new AbortController();
570
+ this.#editorAccess = request;
571
+ this.editorLoading = true;
572
+ this.editorError = undefined;
573
+ this.options.runtime.client
574
+ .collectionAccess(this.options.collection.slug, {
575
+ ...(id === undefined ? { data } : { id }),
576
+ signal: request.signal,
577
+ locale: this.options.locale,
578
+ })
579
+ .then((access) => {
580
+ if (!request.signal.aborted) {
581
+ this.form.setAccess(access, id === undefined ? "create" : "update");
582
+ }
583
+ })
584
+ .catch((cause: unknown) => {
585
+ if (!request.signal.aborted) {
586
+ this.editorError =
587
+ cause instanceof Error
588
+ ? cause.message
589
+ : this.options.runtime.i18n.t("reference:accessCheckFailed");
590
+ }
591
+ })
592
+ .finally(() => {
593
+ if (!request.signal.aborted) {
594
+ this.editorLoading = false;
595
+ this.#editorAccess = undefined;
596
+ }
597
+ });
598
+ }
599
+
600
+ #returnToList() {
601
+ this.#editorAccess?.abort();
602
+ this.#editorAccess = undefined;
603
+ this.screen = "list";
604
+ this.editorDocument = undefined;
605
+ this.editorError = undefined;
606
+ this.selectedFiles = undefined;
607
+ this.newUserPassword = "";
608
+ this.newUserPasswordConfirmation = "";
609
+ this.credentialIssue = undefined;
610
+ this.form.reset({});
611
+ this.form.setAccess(undefined, "update");
612
+ }
613
+
614
+ #closeBrowser() {
615
+ this.options.setOpen(false);
616
+ this.options.onClose();
617
+ }
618
+ }
619
+
620
+ function findDisplayField(fields: readonly SchemaField[]) {
621
+ return (
622
+ fields.find((field) => field.name === "title" || field.name === "name") ??
623
+ fields.find((field) => field.type === "text" && field.name !== "email") ??
624
+ fields.find((field) => field.type === "text" || field.type === "email")
625
+ );
626
+ }
627
+
628
+ function createCollectionFilters(collection: SchemaCollection, i18n: AdminRuntime["i18n"]) {
629
+ const result: FilterOption[] = [{ key: "", label: i18n.t("reference:all"), filter: undefined }];
630
+ if (collection.capabilities.upload) {
631
+ result.push(
632
+ {
633
+ key: "images",
634
+ label: i18n.t("reference:images"),
635
+ filter: { field: "mimeType", operator: "like", value: "image/" },
636
+ },
637
+ {
638
+ key: "documents",
639
+ label: i18n.t("reference:documents"),
640
+ filter: { field: "mimeType", operator: "like", value: "application/" },
641
+ }
642
+ );
643
+ return result;
644
+ }
645
+ const select =
646
+ collection.fields.find((field) => field.name === "status" && field.select !== undefined) ??
647
+ collection.fields.find((field) => field.select !== undefined);
648
+ for (const choice of select?.select?.choices ?? []) {
649
+ result.push({
650
+ key: choice.value,
651
+ label: i18n.text(choice.label, choice.labelTranslations),
652
+ filter: { field: select?.name ?? "", operator: "equals", value: choice.value },
653
+ });
654
+ }
655
+ return result;
656
+ }
657
+
658
+ function scopeField(field: SchemaField, prefix: string): SchemaField {
659
+ return {
660
+ ...field,
661
+ id: `${prefix}-${field.id}`,
662
+ admin: {
663
+ ...field.admin,
664
+ ...(field.admin.row === undefined ? {} : { row: { id: `${prefix}-${field.admin.row.id}` } }),
665
+ },
666
+ ...(field.nested === undefined
667
+ ? {}
668
+ : {
669
+ nested: {
670
+ ...field.nested,
671
+ fields: field.nested.fields.map((child) => scopeField(child, prefix)),
672
+ },
673
+ }),
674
+ ...(field.blocks === undefined
675
+ ? {}
676
+ : {
677
+ blocks: {
678
+ types: field.blocks.types.map((block) => ({
679
+ ...block,
680
+ fields: block.fields.map((child) => scopeField(child, `${prefix}-${block.key}`)),
681
+ })),
682
+ },
683
+ }),
684
+ };
685
+ }
686
+
687
+ function humanize(value: string, language: string) {
688
+ return value
689
+ .replaceAll(/[_-]+/g, " ")
690
+ .replace(/\b\w/g, (letter) => letter.toLocaleUpperCase(language));
691
+ }