@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,717 @@
1
+ import type { AccessCapabilitiesEnvelope, Pagination, SchemaSelectChoice } from "@riducms/protocol";
2
+ import type { AdminI18n } from "@riducms/translations";
3
+
4
+ import type { AdminClient, AdminDocument } from "@admin/core/api/admin-client";
5
+ import type { NotificationCenter } from "@admin/core/notifications/notification-center.svelte";
6
+
7
+ interface CollectionListControllerOptions {
8
+ client: AdminClient;
9
+ notifications: NotificationCenter;
10
+ i18n: AdminI18n;
11
+ get slug(): string;
12
+ get locale(): string | undefined;
13
+ get page(): number;
14
+ get search(): string;
15
+ get titleField(): string | undefined;
16
+ get status(): string;
17
+ get statusField(): string | undefined;
18
+ get statusChoices(): readonly SchemaSelectChoice[];
19
+ get versioned(): boolean;
20
+ get trashOnly(): boolean;
21
+ get folderField(): string | undefined;
22
+ get folderID(): string;
23
+ get hierarchy(): boolean;
24
+ get limit(): number;
25
+ get sort(): readonly string[];
26
+ get filterWhere(): readonly Where[];
27
+ }
28
+
29
+ interface CollectionListRequest {
30
+ slug: string;
31
+ locale: string | undefined;
32
+ page: number;
33
+ limit: number;
34
+ search: string;
35
+ titleField: string | undefined;
36
+ status: string;
37
+ statusField: string | undefined;
38
+ statusValues: readonly string[];
39
+ trashOnly: boolean;
40
+ folderField: string | undefined;
41
+ folderID: string;
42
+ hierarchy: boolean;
43
+ sort: readonly string[];
44
+ filterWhere: readonly Where[];
45
+ }
46
+
47
+ type CollectionListStatus = "initial" | "loading" | "ready" | "failed";
48
+ type Where = Record<string, unknown>;
49
+
50
+ const emptyPagination: Pagination = {
51
+ page: 1,
52
+ limit: 25,
53
+ totalDocs: 0,
54
+ totalPages: 0,
55
+ hasNextPage: false,
56
+ hasPrevPage: false,
57
+ };
58
+
59
+ const maxAtomicSelection = 100;
60
+
61
+ export class CollectionListController {
62
+ showStatus = $state(true);
63
+ showUpdated = $state(true);
64
+ selectedIDs = $state.raw<Set<string>>(new Set());
65
+ #status = $state<CollectionListStatus>("initial");
66
+ #docs = $state.raw<AdminDocument[]>([]);
67
+ #pagination = $state.raw<Pagination>(emptyPagination);
68
+ #statusCounts = $state.raw<Record<string, number>>({});
69
+ #collectionAccess = $state.raw<AccessCapabilitiesEnvelope>();
70
+ #documentAccess = $state.raw<Record<string, AccessCapabilitiesEnvelope>>({});
71
+ #error = $state<string>();
72
+ #request?: AbortController;
73
+ #selectionRequest?: AbortController;
74
+ #requestGeneration = 0;
75
+ #current?: CollectionListRequest;
76
+ #desiredRequestKey = $state("");
77
+ #desiredSelectionQueryKey = $state("");
78
+ #loadedRequestKey = $state<string>();
79
+ #loadedSelectionQueryKey = $state<string>();
80
+ mutationPending = $state.raw<Set<string>>(new Set());
81
+ bulkPending = $state(false);
82
+ selectionPending = $state(false);
83
+
84
+ constructor(readonly options: CollectionListControllerOptions) {
85
+ $effect(() => {
86
+ const request: CollectionListRequest = {
87
+ slug: this.options.slug,
88
+ locale: this.options.locale,
89
+ page: this.options.hierarchy ? 1 : this.options.page,
90
+ limit: this.options.hierarchy ? 100 : this.options.limit,
91
+ search: this.options.search,
92
+ titleField: this.options.titleField,
93
+ status: this.options.status,
94
+ statusField: this.options.statusField,
95
+ statusValues: this.options.statusChoices.map((choice) => choice.value),
96
+ trashOnly: this.options.trashOnly,
97
+ folderField: this.options.folderField,
98
+ folderID: this.options.folderID,
99
+ hierarchy: this.options.hierarchy,
100
+ sort: this.options.sort,
101
+ filterWhere: this.options.filterWhere,
102
+ };
103
+ if (request.slug === "") return;
104
+ const previous = this.#current;
105
+ if (previous !== undefined && selectionQueryKey(previous) !== selectionQueryKey(request)) {
106
+ this.clearSelection();
107
+ }
108
+ if (previous !== undefined && requestKey(previous) !== requestKey(request)) {
109
+ this.#requestGeneration += 1;
110
+ this.#request?.abort();
111
+ this.#request = undefined;
112
+ }
113
+ this.#current = request;
114
+ this.#desiredRequestKey = requestKey(request);
115
+ this.#desiredSelectionQueryKey = selectionQueryKey(request);
116
+ const delay = request.search.trim().length > 0 ? 180 : 0;
117
+ const timer = window.setTimeout(() => this.#open(request), delay);
118
+ return () => window.clearTimeout(timer);
119
+ });
120
+
121
+ $effect(() => () => this.dispose());
122
+ }
123
+
124
+ get status() {
125
+ return this.#status;
126
+ }
127
+
128
+ get docs() {
129
+ return this.#docs;
130
+ }
131
+
132
+ get pagination() {
133
+ return this.#pagination;
134
+ }
135
+
136
+ get statusCounts() {
137
+ return this.#statusCounts;
138
+ }
139
+
140
+ get canCreate() {
141
+ return this.#collectionAccess?.operations.create === true;
142
+ }
143
+
144
+ get canBulkUpdate() {
145
+ return this.#selectedAllow("update");
146
+ }
147
+
148
+ get canBulkPublish() {
149
+ return this.#selectedAllow("publish");
150
+ }
151
+
152
+ get canBulkUnpublish() {
153
+ return this.#selectedAllow("unpublish");
154
+ }
155
+
156
+ get canBulkDelete() {
157
+ return this.#selectedAllow("delete");
158
+ }
159
+
160
+ get canBulkRestore() {
161
+ return this.#selectedAllow("restoreDeleted");
162
+ }
163
+
164
+ get canBulkDeletePermanent() {
165
+ return this.#selectedAllow("deletePermanent");
166
+ }
167
+
168
+ canRestore = (id: string) => this.#documentAccess[id]?.operations.restoreDeleted === true;
169
+
170
+ canDeletePermanent = (id: string) =>
171
+ this.#documentAccess[id]?.operations.deletePermanent === true;
172
+
173
+ canReadField = (path: string, id?: string) => {
174
+ const access = id === undefined ? this.#collectionAccess : this.#documentAccess[id];
175
+ if (access?.operations.read !== true) return false;
176
+ return access.fields[path]?.read !== false;
177
+ };
178
+
179
+ get error() {
180
+ return this.#error;
181
+ }
182
+
183
+ get statusColumnAvailable() {
184
+ return this.options.statusField !== undefined || this.options.versioned;
185
+ }
186
+
187
+ get statusVisible() {
188
+ return this.showStatus && this.statusColumnAvailable;
189
+ }
190
+
191
+ get allDocuments() {
192
+ return this.options.statusField === undefined
193
+ ? this.pagination.totalDocs
194
+ : (this.statusCounts[""] ?? this.pagination.totalDocs);
195
+ }
196
+
197
+ get rangeStart() {
198
+ return this.pagination.totalDocs === 0
199
+ ? 0
200
+ : (this.pagination.page - 1) * this.pagination.limit + 1;
201
+ }
202
+
203
+ get rangeEnd() {
204
+ return Math.min(this.pagination.page * this.pagination.limit, this.pagination.totalDocs);
205
+ }
206
+
207
+ get selectedOnPage() {
208
+ return this.docs.reduce(
209
+ (count, document) => count + Number(this.selectedIDs.has(document.id)),
210
+ 0
211
+ );
212
+ }
213
+
214
+ get allOnPageSelected() {
215
+ return this.docs.length > 0 && this.selectedOnPage === this.docs.length;
216
+ }
217
+
218
+ get pageSelectionIndeterminate() {
219
+ return this.selectedOnPage > 0 && this.selectedOnPage < this.docs.length;
220
+ }
221
+
222
+ get canSelectAllMatches() {
223
+ return (
224
+ this.selectionControlsReady &&
225
+ this.#collectionAccess?.operations.selectAll === true &&
226
+ this.allOnPageSelected &&
227
+ this.selectedIDs.size < this.pagination.totalDocs
228
+ );
229
+ }
230
+
231
+ get showSelectAllMatches() {
232
+ return (
233
+ this.selectionControlsReady &&
234
+ this.#collectionAccess?.operations.selectAll === true &&
235
+ this.allOnPageSelected &&
236
+ this.pagination.totalDocs > this.docs.length
237
+ );
238
+ }
239
+
240
+ get selectionControlsReady() {
241
+ return this.#desiredRequestKey !== "" && this.#loadedRequestKey === this.#desiredRequestKey;
242
+ }
243
+
244
+ get selectionQueryReady() {
245
+ return (
246
+ this.#desiredSelectionQueryKey !== "" &&
247
+ this.#loadedSelectionQueryKey === this.#desiredSelectionQueryKey
248
+ );
249
+ }
250
+
251
+ get selectionLimitExceeded() {
252
+ return this.pagination.totalDocs > maxAtomicSelection;
253
+ }
254
+
255
+ retry = async () => {
256
+ if (this.#current !== undefined) await this.#open(this.#current);
257
+ };
258
+
259
+ setShowStatus = (show: boolean) => {
260
+ this.showStatus = show;
261
+ };
262
+
263
+ setShowUpdated = (show: boolean) => {
264
+ this.showUpdated = show;
265
+ };
266
+
267
+ title = (document: AdminDocument) =>
268
+ String(
269
+ (this.options.titleField === undefined ? undefined : document[this.options.titleField]) ??
270
+ document.id
271
+ );
272
+
273
+ documentStatus = (document: AdminDocument) => {
274
+ if (typeof document._status === "string") return document._status;
275
+ if (this.options.statusField === undefined) return undefined;
276
+ const value = document[this.options.statusField];
277
+ return typeof value === "string" ? value : undefined;
278
+ };
279
+
280
+ statusLabel = (value: string) => {
281
+ const configured = this.options.statusChoices.find((choice) => choice.value === value)?.label;
282
+ if (configured !== undefined) return configured;
283
+ const normalized = value.toLocaleLowerCase(this.options.i18n.language);
284
+ if (normalized === "published") return this.options.i18n.t("documents:published");
285
+ if (normalized === "draft") return this.options.i18n.t("documents:draft");
286
+ return humanize(value, this.options.i18n.language);
287
+ };
288
+
289
+ statusTone = (value: string) => {
290
+ const normalized = value.toLocaleLowerCase(this.options.i18n.language);
291
+ if (normalized === "published" || normalized === "live") return "live";
292
+ if (normalized === "draft") return "muted";
293
+ return "warning";
294
+ };
295
+
296
+ updated = (document: AdminDocument) => formatUpdated(document.updatedAt, this.options.i18n);
297
+ created = (document: AdminDocument) => formatUpdated(document.createdAt, this.options.i18n);
298
+
299
+ choiceCount = (choice: SchemaSelectChoice) => this.statusCounts[choice.value];
300
+
301
+ toggleDocument = (id: string, checked: boolean) => {
302
+ if (!this.selectionControlsReady || !this.docs.some((document) => document.id === id)) return;
303
+ this.#cancelSelectionRequest();
304
+ const selected = new Set(this.selectedIDs);
305
+ if (checked) selected.add(id);
306
+ else selected.delete(id);
307
+ this.selectedIDs = selected;
308
+ };
309
+
310
+ togglePage = (checked: boolean) => {
311
+ if (!this.selectionControlsReady) return;
312
+ this.#cancelSelectionRequest();
313
+ const selected = new Set(this.selectedIDs);
314
+ for (const document of this.docs) {
315
+ if (checked) selected.add(document.id);
316
+ else selected.delete(document.id);
317
+ }
318
+ this.selectedIDs = selected;
319
+ };
320
+
321
+ clearSelection = () => {
322
+ this.#cancelSelectionRequest();
323
+ this.selectedIDs = new Set();
324
+ };
325
+
326
+ selectAllMatches = async () => {
327
+ const request = this.#current;
328
+ if (
329
+ request === undefined ||
330
+ !this.canSelectAllMatches ||
331
+ this.selectionLimitExceeded ||
332
+ this.selectionPending
333
+ )
334
+ return;
335
+ const selectionKey = selectionQueryKey(request);
336
+ this.#selectionRequest?.abort();
337
+ const activeRequest = new AbortController();
338
+ this.#selectionRequest = activeRequest;
339
+ this.selectionPending = true;
340
+ try {
341
+ const selection = await this.options.client.resolveFilteredSelection(request.slug, {
342
+ where: buildWhere(request),
343
+ trash: request.trashOnly,
344
+ locale: request.locale,
345
+ signal: activeRequest.signal,
346
+ });
347
+ if (
348
+ activeRequest.signal.aborted ||
349
+ this.#current === undefined ||
350
+ selectionKey !== selectionQueryKey(this.#current)
351
+ )
352
+ return;
353
+ this.selectedIDs = new Set(selection.items.map((item) => item.id));
354
+ this.#documentAccess = Object.fromEntries(
355
+ selection.items.map((item) => [item.id, item.access] as const)
356
+ );
357
+ } catch (cause) {
358
+ if (activeRequest.signal.aborted) return;
359
+ this.options.notifications.error({
360
+ title: this.options.i18n.t("collections:allDocumentsNotSelected"),
361
+ message:
362
+ cause instanceof Error
363
+ ? cause.message
364
+ : this.options.i18n.t("collections:filteredResultLoadFailed"),
365
+ });
366
+ } finally {
367
+ if (this.#selectionRequest === activeRequest) {
368
+ this.#selectionRequest = undefined;
369
+ this.selectionPending = false;
370
+ }
371
+ }
372
+ };
373
+
374
+ restore = async (id: string) => {
375
+ await this.#mutate(id, "restore");
376
+ };
377
+
378
+ deletePermanent = async (id: string) => {
379
+ await this.#mutate(id, "permanent");
380
+ };
381
+
382
+ bulkUpdate = async (data: Record<string, unknown>) => {
383
+ return this.#bulk("update", data);
384
+ };
385
+
386
+ bulkPublish = async () => {
387
+ await this.#bulk("publish");
388
+ };
389
+
390
+ bulkUnpublish = async () => {
391
+ await this.#bulk("unpublish");
392
+ };
393
+
394
+ bulkDelete = async () => {
395
+ await this.#bulk("delete");
396
+ };
397
+
398
+ bulkRestore = async () => {
399
+ await this.#bulk("restoreDeleted");
400
+ };
401
+
402
+ bulkDeletePermanent = async () => {
403
+ await this.#bulk("deletePermanent");
404
+ };
405
+
406
+ emptyTrash = async () => {
407
+ if (this.bulkPending) return;
408
+ this.bulkPending = true;
409
+ try {
410
+ const documents = await this.options.client.emptyTrash(this.options.slug);
411
+ this.clearSelection();
412
+ this.options.notifications.success({
413
+ title: this.options.i18n.t("collections:documentsPermanentlyDeleted", {
414
+ count: documents.length,
415
+ formattedCount: this.options.i18n.formatNumber(documents.length),
416
+ }),
417
+ });
418
+ if (this.#current !== undefined) await this.#open(this.#current);
419
+ } catch (cause) {
420
+ this.options.notifications.error({
421
+ title: this.options.i18n.t("collections:trashNotEmptied"),
422
+ message:
423
+ cause instanceof Error
424
+ ? cause.message
425
+ : this.options.i18n.t("collections:noDocumentsChanged"),
426
+ });
427
+ } finally {
428
+ this.bulkPending = false;
429
+ }
430
+ };
431
+
432
+ dispose() {
433
+ this.#requestGeneration += 1;
434
+ this.#request?.abort();
435
+ this.#request = undefined;
436
+ this.#cancelSelectionRequest();
437
+ }
438
+
439
+ #cancelSelectionRequest() {
440
+ this.#selectionRequest?.abort();
441
+ this.#selectionRequest = undefined;
442
+ this.selectionPending = false;
443
+ }
444
+
445
+ #open = async (request: CollectionListRequest) => {
446
+ if (
447
+ this.#current !== undefined &&
448
+ selectionQueryKey(this.#current) !== selectionQueryKey(request)
449
+ ) {
450
+ this.clearSelection();
451
+ }
452
+ this.#current = request;
453
+ this.#request?.abort();
454
+ const activeRequest = new AbortController();
455
+ const generation = ++this.#requestGeneration;
456
+ this.#request = activeRequest;
457
+ this.#status = "loading";
458
+ this.#error = undefined;
459
+
460
+ try {
461
+ const [page, statusCounts, collectionAccess] = await Promise.all([
462
+ this.options.client.list(request.slug, {
463
+ page: request.page,
464
+ limit: request.limit,
465
+ where: buildWhere(request),
466
+ sort: request.sort,
467
+ trash: request.trashOnly,
468
+ locale: request.locale,
469
+ signal: activeRequest.signal,
470
+ }),
471
+ this.#loadStatusCounts(request, activeRequest.signal),
472
+ this.options.client.collectionAccess(request.slug, {
473
+ trash: request.trashOnly,
474
+ locale: request.locale,
475
+ signal: activeRequest.signal,
476
+ }),
477
+ ]);
478
+ const documentAccessEntries = await Promise.all(
479
+ page.docs.map(async (document) => {
480
+ try {
481
+ return [
482
+ document.id,
483
+ await this.options.client.collectionAccess(request.slug, {
484
+ id: document.id,
485
+ trash: request.trashOnly,
486
+ locale: request.locale,
487
+ signal: activeRequest.signal,
488
+ }),
489
+ ] as const;
490
+ } catch {
491
+ return undefined;
492
+ }
493
+ })
494
+ );
495
+ if (activeRequest.signal.aborted || generation !== this.#requestGeneration) return;
496
+ this.#docs = page.docs;
497
+ this.#pagination = page.pagination;
498
+ this.#statusCounts = statusCounts;
499
+ this.#collectionAccess = collectionAccess;
500
+ const visibleAccess = Object.fromEntries(
501
+ documentAccessEntries.filter((entry) => entry !== undefined)
502
+ );
503
+ this.#documentAccess = {
504
+ ...Object.fromEntries(
505
+ Object.entries(this.#documentAccess).filter(([id]) => this.selectedIDs.has(id))
506
+ ),
507
+ ...visibleAccess,
508
+ };
509
+ this.#loadedRequestKey = requestKey(request);
510
+ this.#loadedSelectionQueryKey = selectionQueryKey(request);
511
+ this.#status = "ready";
512
+ } catch (cause) {
513
+ if (activeRequest.signal.aborted || generation !== this.#requestGeneration) return;
514
+ this.#error =
515
+ cause instanceof Error
516
+ ? cause.message
517
+ : this.options.i18n.t("collections:documentsLoadFailed");
518
+ this.#status = "failed";
519
+ } finally {
520
+ if (this.#request === activeRequest) this.#request = undefined;
521
+ }
522
+ };
523
+
524
+ async #loadStatusCounts(request: CollectionListRequest, signal: AbortSignal) {
525
+ if (request.statusField === undefined || request.statusValues.length === 0) return {};
526
+ const entries = await Promise.all([
527
+ this.options.client
528
+ .count(request.slug, {
529
+ where: buildWhere(request, ""),
530
+ trash: request.trashOnly,
531
+ locale: request.locale,
532
+ signal,
533
+ })
534
+ .then((result) => ["", result.totalDocs] as const),
535
+ ...request.statusValues.map(async (status) => {
536
+ const result = await this.options.client.count(request.slug, {
537
+ where: buildWhere(request, status),
538
+ trash: request.trashOnly,
539
+ locale: request.locale,
540
+ signal,
541
+ });
542
+ return [status, result.totalDocs] as const;
543
+ }),
544
+ ]);
545
+ return Object.fromEntries(entries);
546
+ }
547
+
548
+ #selectedAllow(
549
+ operation: "update" | "delete" | "publish" | "unpublish" | "restoreDeleted" | "deletePermanent"
550
+ ) {
551
+ return (
552
+ this.selectedIDs.size > 0 &&
553
+ [...this.selectedIDs].every((id) => this.#documentAccess[id]?.operations[operation] === true)
554
+ );
555
+ }
556
+
557
+ async #mutate(id: string, action: "restore" | "permanent") {
558
+ if (this.mutationPending.has(id)) return;
559
+ this.mutationPending = new Set(this.mutationPending).add(id);
560
+ try {
561
+ if (action === "restore") await this.options.client.restoreDeleted(this.options.slug, id);
562
+ else await this.options.client.deletePermanent(this.options.slug, id);
563
+ this.options.notifications.success({
564
+ title:
565
+ action === "restore"
566
+ ? this.options.i18n.t("collections:documentRestored")
567
+ : this.options.i18n.t("collections:documentPermanentlyDeleted"),
568
+ });
569
+ if (this.#current !== undefined) await this.#open(this.#current);
570
+ } catch (cause) {
571
+ this.options.notifications.error({
572
+ title:
573
+ action === "restore"
574
+ ? this.options.i18n.t("collections:documentNotRestored")
575
+ : this.options.i18n.t("collections:documentNotDeleted"),
576
+ message:
577
+ cause instanceof Error
578
+ ? cause.message
579
+ : this.options.i18n.t("collections:operationFailed"),
580
+ });
581
+ } finally {
582
+ const pending = new Set(this.mutationPending);
583
+ pending.delete(id);
584
+ this.mutationPending = pending;
585
+ }
586
+ }
587
+
588
+ async #bulk(
589
+ action: "update" | "publish" | "unpublish" | "delete" | "restoreDeleted" | "deletePermanent",
590
+ data?: Record<string, unknown>
591
+ ) {
592
+ const ids = [...this.selectedIDs];
593
+ if (ids.length === 0 || this.bulkPending || !this.selectionQueryReady) return false;
594
+ this.bulkPending = true;
595
+ try {
596
+ if (action === "update")
597
+ await this.options.client.bulkUpdate(this.options.slug, ids, data ?? {}, {
598
+ locale: this.options.locale,
599
+ });
600
+ else if (action === "publish")
601
+ await this.options.client.bulkPublish(this.options.slug, ids, {
602
+ locale: this.options.locale,
603
+ });
604
+ else if (action === "unpublish")
605
+ await this.options.client.bulkUnpublish(this.options.slug, ids, {
606
+ locale: this.options.locale,
607
+ });
608
+ else if (action === "delete")
609
+ await this.options.client.bulkDelete(this.options.slug, ids, {
610
+ locale: this.options.locale,
611
+ });
612
+ else if (action === "restoreDeleted")
613
+ await this.options.client.bulkRestoreDeleted(this.options.slug, ids, {
614
+ locale: this.options.locale,
615
+ });
616
+ else
617
+ await this.options.client.bulkDeletePermanent(this.options.slug, ids, {
618
+ locale: this.options.locale,
619
+ });
620
+ this.clearSelection();
621
+ this.options.notifications.success({
622
+ title: this.options.i18n.t(bulkTranslationKey(action), {
623
+ count: ids.length,
624
+ formattedCount: this.options.i18n.formatNumber(ids.length),
625
+ }),
626
+ });
627
+ if (this.#current !== undefined) await this.#open(this.#current);
628
+ return true;
629
+ } catch (cause) {
630
+ this.options.notifications.error({
631
+ title: this.options.i18n.t("collections:bulkOperationFailed"),
632
+ message:
633
+ cause instanceof Error
634
+ ? cause.message
635
+ : this.options.i18n.t("collections:noDocumentsChanged"),
636
+ });
637
+ return false;
638
+ } finally {
639
+ this.bulkPending = false;
640
+ }
641
+ }
642
+ }
643
+
644
+ function bulkTranslationKey(
645
+ action: "update" | "publish" | "unpublish" | "delete" | "restoreDeleted" | "deletePermanent"
646
+ ) {
647
+ if (action === "update") return "collections:documentsUpdated" as const;
648
+ if (action === "publish") return "collections:documentsPublished" as const;
649
+ if (action === "unpublish") return "collections:documentsUnpublished" as const;
650
+ if (action === "restoreDeleted") return "collections:documentsRestored" as const;
651
+ if (action === "deletePermanent") return "collections:documentsPermanentlyDeleted" as const;
652
+ return "collections:documentsDeleted" as const;
653
+ }
654
+
655
+ function buildWhere(request: CollectionListRequest, status = request.status) {
656
+ const filters: Where[] = [];
657
+ const search = request.search.trim();
658
+ if (search.length > 0 && request.titleField !== undefined) {
659
+ filters.push({ [request.titleField]: { like: search } });
660
+ }
661
+ if (status !== "" && request.statusField !== undefined) {
662
+ filters.push({ [request.statusField]: { equals: status } });
663
+ }
664
+ if (request.folderID !== "" && request.folderField !== undefined) {
665
+ filters.push({ [request.folderField]: { equals: request.folderID } });
666
+ }
667
+ filters.push(...request.filterWhere);
668
+ if (filters.length === 0) return undefined;
669
+ if (filters.length === 1) return filters[0];
670
+ return { and: filters };
671
+ }
672
+
673
+ function selectionQueryKey(request: CollectionListRequest) {
674
+ return JSON.stringify({
675
+ slug: request.slug,
676
+ locale: request.locale,
677
+ search: request.search,
678
+ titleField: request.titleField,
679
+ status: request.status,
680
+ statusField: request.statusField,
681
+ trashOnly: request.trashOnly,
682
+ folderField: request.folderField,
683
+ folderID: request.folderID,
684
+ hierarchy: request.hierarchy,
685
+ sort: request.sort,
686
+ filterWhere: request.filterWhere,
687
+ });
688
+ }
689
+
690
+ function requestKey(request: CollectionListRequest) {
691
+ return JSON.stringify(request);
692
+ }
693
+
694
+ function humanize(value: string, language: string) {
695
+ return value
696
+ .replaceAll(/[_-]+/g, " ")
697
+ .replace(/\b\w/g, (letter) => letter.toLocaleUpperCase(language));
698
+ }
699
+
700
+ function formatUpdated(value: string | undefined, i18n: AdminI18n) {
701
+ if (value === undefined) return "—";
702
+ const date = new Date(value);
703
+ if (Number.isNaN(date.valueOf())) return value;
704
+ const seconds = Math.round((date.valueOf() - Date.now()) / 1_000);
705
+ if (Math.abs(seconds) < 60) return i18n.formatRelativeTime(seconds, "second");
706
+ const minutes = Math.round(seconds / 60);
707
+ if (Math.abs(minutes) < 60) return i18n.formatRelativeTime(minutes, "minute");
708
+ const hours = Math.round(minutes / 60);
709
+ if (Math.abs(hours) < 24) return i18n.formatRelativeTime(hours, "hour");
710
+ const days = Math.round(hours / 24);
711
+ if (Math.abs(days) < 7) return i18n.formatRelativeTime(days, "day");
712
+ return i18n.formatDate(date, {
713
+ day: "2-digit",
714
+ month: "short",
715
+ year: date.getFullYear() === new Date().getFullYear() ? undefined : "numeric",
716
+ });
717
+ }