@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,624 @@
1
+ <script lang="ts">
2
+ import { Link, useLocation, useParams } from "@hvniel/svelte-router";
3
+ import type { SchemaField, ValidationIssue } from "@riducms/protocol";
4
+ import { RiduError } from "@riducms/sdk";
5
+ import { tick } from "svelte";
6
+ import ArrowLeftIcon from "~icons/lucide/arrow-left";
7
+ import CheckIcon from "~icons/lucide/check";
8
+ import FileUpIcon from "~icons/lucide/file-up";
9
+ import RotateCcwIcon from "~icons/lucide/rotate-ccw";
10
+ import Trash2Icon from "~icons/lucide/trash-2";
11
+ import LinkIcon from "~icons/lucide/link";
12
+
13
+ import { Banner } from "@admin/components/ui/banner";
14
+ import { Button, buttonVariants } from "@admin/components/ui/button";
15
+ import { Input } from "@admin/components/ui/input";
16
+ import { getNotificationCenter } from "@admin/core/notifications/notification-center.svelte";
17
+ import { collectionPath, documentPath } from "@admin/core/routing/admin-paths";
18
+ import { getAdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
19
+ import {
20
+ bulkUploadMetadataFields,
21
+ initialBulkUploadValues,
22
+ uploadData,
23
+ } from "@admin/features/uploads/bulk-upload";
24
+ import UploadMetadataFields from "@admin/features/uploads/upload-metadata-fields.svelte";
25
+
26
+ type QueueStatus = "queued" | "uploading" | "complete" | "failed" | "uncertain";
27
+ type QueueItem = {
28
+ id: string;
29
+ file: File;
30
+ values: Record<string, string>;
31
+ status: QueueStatus;
32
+ error?: string;
33
+ issues: readonly ValidationIssue[];
34
+ documentID?: string;
35
+ };
36
+
37
+ const runtime = getAdminRuntime();
38
+ const notifications = getNotificationCenter();
39
+ const params = $derived(useParams<"collection">());
40
+ const location = $derived(useLocation());
41
+ const slug = $derived(params.collection ?? "");
42
+ const collection = $derived(runtime.manifest?.collections.find((item) => item.slug === slug));
43
+ const requestedLocale = $derived(new URLSearchParams(location.search).get("locale"));
44
+ const contentLocale = $derived(
45
+ runtime.manifest?.application.localization?.locales.some(
46
+ (locale) => locale.code === requestedLocale
47
+ )
48
+ ? (requestedLocale ?? undefined)
49
+ : undefined
50
+ );
51
+ const metadataFields = $derived(bulkUploadMetadataFields(collection?.fields ?? []));
52
+ const acceptedTypes = $derived(collection?.uploadSettings?.mimeTypes?.join(",") ?? "");
53
+ const acceptedTypeLabels = $derived(
54
+ runtime.i18n.formatList(collection?.uploadSettings?.mimeTypes ?? [], {
55
+ style: "short",
56
+ type: "disjunction",
57
+ })
58
+ );
59
+ const uploadEnabled = $derived(collection?.capabilities.upload === true);
60
+ let queue = $state<QueueItem[]>([]);
61
+ let running = $state(false);
62
+ let accessLoading = $state(true);
63
+ let canCreate = $state(false);
64
+ let accessError = $state<string>();
65
+ let remoteURL = $state("");
66
+ let remoteValues = $state<Record<string, string>>({});
67
+ let remotePending = $state(false);
68
+ let remoteError = $state<string>();
69
+ let remoteOutcomeUncertain = $state(false);
70
+ let remoteAttemptURL = "";
71
+ let remoteIssues = $state<readonly ValidationIssue[]>([]);
72
+ let remoteDocumentID = $state<string>();
73
+ let nextID = 0;
74
+ let pageElement: HTMLElement;
75
+ let queueRequest: AbortController | undefined;
76
+ let remoteRequest: AbortController | undefined;
77
+ const completed = $derived(queue.filter((item) => item.status === "complete").length);
78
+ const failed = $derived(queue.filter((item) => item.status === "failed").length);
79
+ const uncertain = $derived(queue.filter((item) => item.status === "uncertain").length);
80
+ const pending = $derived(queue.filter((item) => item.status === "queued").length);
81
+ const currentUpload = $derived(queue.find((item) => item.status === "uploading"));
82
+
83
+ $effect(() => {
84
+ const currentSlug = slug;
85
+ queueRequest?.abort();
86
+ remoteRequest?.abort();
87
+ queueRequest = undefined;
88
+ remoteRequest = undefined;
89
+ queue = [];
90
+ running = false;
91
+ canCreate = false;
92
+ remoteURL = "";
93
+ remoteValues = {};
94
+ remotePending = false;
95
+ remoteError = undefined;
96
+ remoteOutcomeUncertain = false;
97
+ remoteAttemptURL = "";
98
+ remoteIssues = [];
99
+ remoteDocumentID = undefined;
100
+ const request = new AbortController();
101
+ accessLoading = true;
102
+ accessError = undefined;
103
+ runtime.client
104
+ .collectionAccess(currentSlug, { signal: request.signal, locale: contentLocale })
105
+ .then((access) => {
106
+ if (!request.signal.aborted) canCreate = access.operations.create;
107
+ })
108
+ .catch((cause: unknown) => {
109
+ if (!request.signal.aborted) {
110
+ accessError =
111
+ cause instanceof Error ? cause.message : runtime.i18n.t("uploads:accessCheckFailed");
112
+ }
113
+ })
114
+ .finally(() => {
115
+ if (!request.signal.aborted) accessLoading = false;
116
+ });
117
+ return () => {
118
+ request.abort();
119
+ queueRequest?.abort();
120
+ remoteRequest?.abort();
121
+ };
122
+ });
123
+
124
+ function addFiles(event: Event) {
125
+ if (!uploadEnabled || !canCreate || running) return;
126
+ const input = event.currentTarget as HTMLInputElement;
127
+ const files = Array.from(input.files ?? []);
128
+ const known = new Set(queue.map((item) => fileIdentity(item.file)));
129
+ for (const file of files) {
130
+ if (known.has(fileIdentity(file))) continue;
131
+ known.add(fileIdentity(file));
132
+ queue.push({
133
+ id: `upload-${++nextID}`,
134
+ file,
135
+ values: initialBulkUploadValues(file.name, metadataFields, collection?.admin.useAsTitle),
136
+ status: "queued",
137
+ issues: [],
138
+ });
139
+ }
140
+ input.value = "";
141
+ }
142
+
143
+ function removeItem(id: string) {
144
+ queue = queue.filter((item) => item.id !== id);
145
+ }
146
+
147
+ function refreshQueue() {
148
+ queue = [...queue];
149
+ }
150
+
151
+ async function retry(item: QueueItem, trigger: HTMLElement) {
152
+ if (running || !uploadEnabled || !canCreate) return;
153
+ const row = trigger.closest<HTMLElement>("[data-upload-row]");
154
+ item.status = "queued";
155
+ item.error = undefined;
156
+ item.issues = [];
157
+ refreshQueue();
158
+ await tick();
159
+ row?.focus();
160
+ await runUploads([item]);
161
+ await tick();
162
+ const finalStatus = item.status as QueueStatus;
163
+ if (finalStatus === "complete") {
164
+ row?.querySelector<HTMLElement>("[data-upload-result-action]")?.focus();
165
+ } else if (finalStatus === "failed" && item.issues.length === 0) {
166
+ row?.querySelector<HTMLElement>("[data-upload-result-action]")?.focus();
167
+ } else if (finalStatus === "uncertain") {
168
+ row?.focus();
169
+ }
170
+ }
171
+
172
+ async function uploadAll() {
173
+ await runUploads(queue);
174
+ }
175
+
176
+ async function runUploads(items: readonly QueueItem[]) {
177
+ if (running || !uploadEnabled || !canCreate) return;
178
+ const currentSlug = slug;
179
+ const request = new AbortController();
180
+ queueRequest?.abort();
181
+ queueRequest = request;
182
+ running = true;
183
+ for (const item of items) {
184
+ if (request.signal.aborted) return;
185
+ if (item.status !== "queued") continue;
186
+ item.status = "uploading";
187
+ item.error = undefined;
188
+ item.issues = [];
189
+ refreshQueue();
190
+ try {
191
+ const data = uploadData(item.values);
192
+ const document = await runtime.client.upload(currentSlug, item.file, {
193
+ data,
194
+ signal: request.signal,
195
+ locale: contentLocale,
196
+ });
197
+ if (request.signal.aborted || slug !== currentSlug) return;
198
+ item.status = "complete";
199
+ item.documentID = document.id;
200
+ refreshQueue();
201
+ runtime.documentsChanged();
202
+ } catch (cause) {
203
+ if (request.signal.aborted) return;
204
+ const confirmed = cause instanceof RiduError && cause.status < 500;
205
+ item.status = confirmed ? "failed" : "uncertain";
206
+ item.error = confirmed ? cause.message : runtime.i18n.t("uploads:outcomeNotConfirmed");
207
+ item.issues = confirmed ? cause.issues : [];
208
+ refreshQueue();
209
+ }
210
+ }
211
+ if (request.signal.aborted || slug !== currentSlug) return;
212
+ running = false;
213
+ queueRequest = undefined;
214
+ const failures = queue.filter((item) => item.status === "failed").length;
215
+ const successes = queue.filter((item) => item.status === "complete").length;
216
+ const uncertainOutcomes = queue.filter((item) => item.status === "uncertain").length;
217
+ if (failures === 0 && uncertainOutcomes === 0 && successes > 0) {
218
+ notifications.success({
219
+ title: runtime.i18n.t("uploads:assetsUploaded", { count: successes }),
220
+ });
221
+ } else if (failures > 0 || uncertainOutcomes > 0) {
222
+ notifications.error({
223
+ title: runtime.i18n.t("uploads:needAttention", {
224
+ count: failures + uncertainOutcomes,
225
+ }),
226
+ message:
227
+ uncertainOutcomes > 0
228
+ ? runtime.i18n.t("uploads:unknownOutcomesDescription")
229
+ : runtime.i18n.t("uploads:correctMetadata"),
230
+ });
231
+ if (failures > 0) await focusFirstInvalid();
232
+ }
233
+ }
234
+
235
+ async function uploadRemote(event: SubmitEvent) {
236
+ event.preventDefault();
237
+ if (remotePending || !uploadEnabled || !canCreate || remoteURL.trim() === "") return;
238
+ const currentSlug = slug;
239
+ const request = new AbortController();
240
+ remoteRequest?.abort();
241
+ remoteRequest = request;
242
+ remotePending = true;
243
+ remoteError = undefined;
244
+ remoteOutcomeUncertain = false;
245
+ remoteAttemptURL = remoteURL.trim();
246
+ remoteIssues = [];
247
+ remoteDocumentID = undefined;
248
+ try {
249
+ const defaults = initialBulkUploadValues(
250
+ remoteFilename(remoteURL),
251
+ metadataFields,
252
+ collection?.admin.useAsTitle
253
+ );
254
+ const document = await runtime.client.uploadFromURL(currentSlug, remoteURL.trim(), {
255
+ data: uploadData({ ...defaults, ...remoteValues }),
256
+ signal: request.signal,
257
+ locale: contentLocale,
258
+ });
259
+ if (request.signal.aborted || slug !== currentSlug) return;
260
+ remoteDocumentID = document.id;
261
+ runtime.documentsChanged();
262
+ notifications.success({ title: runtime.i18n.t("uploads:remoteAssetUploaded") });
263
+ } catch (cause) {
264
+ if (request.signal.aborted) return;
265
+ const confirmed = cause instanceof RiduError && cause.status < 500;
266
+ remoteOutcomeUncertain = !confirmed;
267
+ remoteError = confirmed ? cause.message : runtime.i18n.t("uploads:remoteOutcomeNotConfirmed");
268
+ remoteIssues = confirmed ? cause.issues : [];
269
+ if (remoteIssues.length > 0) await focusFirstInvalid("[data-remote-upload]");
270
+ } finally {
271
+ if (!request.signal.aborted && slug === currentSlug) {
272
+ remotePending = false;
273
+ remoteRequest = undefined;
274
+ }
275
+ }
276
+ }
277
+
278
+ function clearItemIssues(item: QueueItem, path: string, name: string) {
279
+ item.issues = item.issues.filter((issue) => issue.path !== path && issue.path !== name);
280
+ if (item.issues.length === 0 && item.status === "failed") item.error = undefined;
281
+ refreshQueue();
282
+ }
283
+
284
+ function clearRemoteIssues(path: string, name: string) {
285
+ remoteIssues = remoteIssues.filter((issue) => issue.path !== path && issue.path !== name);
286
+ if (remoteIssues.length === 0) remoteError = undefined;
287
+ }
288
+
289
+ function updateRemoteMetadata(field: SchemaField, value: string) {
290
+ remoteValues[field.name] = value;
291
+ clearRemoteIssues(field.path, field.name);
292
+ }
293
+
294
+ function updateItemMetadata(item: QueueItem, field: SchemaField, value: string) {
295
+ item.values = { ...item.values, [field.name]: value };
296
+ clearItemIssues(item, field.path, field.name);
297
+ }
298
+
299
+ function updateRemoteURL(value: string) {
300
+ remoteURL = value;
301
+ if (remoteOutcomeUncertain && value.trim() !== remoteAttemptURL) {
302
+ remoteOutcomeUncertain = false;
303
+ remoteError = undefined;
304
+ }
305
+ }
306
+
307
+ async function focusFirstInvalid(scope = "") {
308
+ await tick();
309
+ const selector = scope === "" ? '[aria-invalid="true"]' : `${scope} [aria-invalid="true"]`;
310
+ const invalid = pageElement?.querySelector<HTMLElement>(selector);
311
+ invalid?.scrollIntoView({ behavior: "smooth", block: "center" });
312
+ invalid?.focus({ preventScroll: true });
313
+ }
314
+
315
+ function fileIdentity(file: File) {
316
+ return `${file.name}\u0000${file.size}\u0000${file.lastModified}`;
317
+ }
318
+
319
+ function remoteFilename(value: string) {
320
+ try {
321
+ const filename = new URL(value).pathname.split("/").filter(Boolean).at(-1);
322
+ return filename === undefined ? "remote-upload" : decodeURIComponent(filename);
323
+ } catch {
324
+ return "remote-upload";
325
+ }
326
+ }
327
+
328
+ function formatBytes(value: number) {
329
+ if (value < 1_024) return runtime.i18n.formatNumber(value, { style: "unit", unit: "byte" });
330
+ if (value < 1_048_576)
331
+ return runtime.i18n.formatNumber(value / 1_024, {
332
+ maximumFractionDigits: 0,
333
+ style: "unit",
334
+ unit: "kilobyte",
335
+ });
336
+ return runtime.i18n.formatNumber(value / 1_048_576, {
337
+ maximumFractionDigits: 1,
338
+ style: "unit",
339
+ unit: "megabyte",
340
+ });
341
+ }
342
+
343
+ function statusLabel(status: QueueStatus) {
344
+ return runtime.i18n.t(
345
+ `uploads:status${status[0]?.toLocaleUpperCase("en-US")}${status.slice(1)}` as
346
+ | "uploads:statusQueued"
347
+ | "uploads:statusUploading"
348
+ | "uploads:statusComplete"
349
+ | "uploads:statusFailed"
350
+ | "uploads:statusUncertain"
351
+ );
352
+ }
353
+
354
+ function localizedPath(path: string) {
355
+ return contentLocale === undefined
356
+ ? path
357
+ : `${path}?${new URLSearchParams({ locale: contentLocale })}`;
358
+ }
359
+ </script>
360
+
361
+ <section
362
+ bind:this={pageElement}
363
+ class="mx-auto w-full max-w-[1020px] px-5 py-9 sm:px-8 sm:py-11 lg:px-11 lg:pb-22.5"
364
+ aria-busy={running || remotePending}
365
+ >
366
+ <header class="flex flex-wrap items-end justify-between gap-5">
367
+ <div>
368
+ <Link
369
+ class="mb-5 inline-flex items-center gap-1.5 text-[12px] text-foreground-faint hover:text-foreground"
370
+ to={localizedPath(collectionPath(slug))}
371
+ >
372
+ <ArrowLeftIcon class="size-3.5 rtl:rotate-180" />
373
+ {collection === undefined
374
+ ? slug
375
+ : runtime.i18n.text(collection.labels.plural, collection.labels.pluralTranslations)}
376
+ </Link>
377
+ <h1 class="font-serif text-[34px] leading-none tracking-[0.004em] text-foreground">
378
+ {runtime.i18n.t("uploads:bulkUpload")}
379
+ </h1>
380
+ <p class="mt-2 max-w-xl text-[13px] leading-5 text-foreground-sub">
381
+ {runtime.i18n.t("uploads:bulkUploadDescription")}
382
+ </p>
383
+ </div>
384
+ <div class="flex items-center gap-2">
385
+ {#if queue.length > 0}
386
+ <span
387
+ class="font-mono me-2 text-[10px] text-foreground-faint"
388
+ role="status"
389
+ aria-live="polite"
390
+ >
391
+ {runtime.i18n.t("uploads:queueSummary", {
392
+ current:
393
+ currentUpload === undefined
394
+ ? ""
395
+ : runtime.i18n.t("uploads:uploadingFilename", {
396
+ filename: currentUpload.file.name,
397
+ }),
398
+ complete: runtime.i18n.formatNumber(completed),
399
+ queued: runtime.i18n.formatNumber(pending),
400
+ failed: runtime.i18n.formatNumber(failed),
401
+ uncertain: runtime.i18n.formatNumber(uncertain),
402
+ })}
403
+ </span>
404
+ {/if}
405
+ <Button
406
+ disabled={running || pending === 0 || !uploadEnabled || !canCreate}
407
+ onclick={uploadAll}
408
+ >
409
+ <FileUpIcon class="size-3.5" />
410
+ {running
411
+ ? runtime.i18n.t("uploads:uploading")
412
+ : runtime.i18n.t("uploads:uploadCount", {
413
+ count: pending === 0 ? runtime.i18n.t("uploads:files") : pending,
414
+ })}
415
+ </Button>
416
+ </div>
417
+ </header>
418
+
419
+ {const accessNotice = $derived(
420
+ collection !== undefined && collection.capabilities.upload !== true
421
+ ? {
422
+ message: runtime.i18n.t("uploads:notConfigured"),
423
+ tone: "destructive" as const,
424
+ }
425
+ : accessError !== undefined
426
+ ? { message: accessError, tone: "destructive" as const }
427
+ : !accessLoading && !canCreate
428
+ ? {
429
+ message: runtime.i18n.t("uploads:createPermissionDenied"),
430
+ tone: "warning" as const,
431
+ }
432
+ : undefined
433
+ )}
434
+
435
+ {#if accessNotice !== undefined}
436
+ <Banner class="mt-7" tone={accessNotice.tone}>{accessNotice.message}</Banner>
437
+ {/if}
438
+
439
+ <label
440
+ class="mt-7 grid min-h-36 cursor-pointer place-items-center rounded-[4px] border border-dashed border-control-border-hover bg-control px-5 py-7 text-center transition-colors hover:border-primary/55 hover:bg-primary/[0.035]"
441
+ >
442
+ <input
443
+ class="sr-only"
444
+ type="file"
445
+ multiple
446
+ accept={acceptedTypes}
447
+ onchange={addFiles}
448
+ disabled={!uploadEnabled || !canCreate || running}
449
+ />
450
+ <span>
451
+ <FileUpIcon class="mx-auto size-5 text-foreground-faint" />
452
+ <span class="mt-3 block text-[13.5px] font-medium text-foreground-muted">
453
+ {runtime.i18n.t("uploads:chooseFilesForQueue")}
454
+ </span>
455
+ <span class="mt-1 block text-[11.5px] text-foreground-faint">
456
+ {acceptedTypeLabels || runtime.i18n.t("uploads:configuredTypes")}
457
+ </span>
458
+ </span>
459
+ </label>
460
+
461
+ <form
462
+ class="mt-4 grid gap-4 rounded-[4px] border border-control-border bg-control p-5"
463
+ data-remote-upload
464
+ onsubmit={uploadRemote}
465
+ >
466
+ <div class="flex items-start gap-3">
467
+ <div
468
+ class="grid size-9 shrink-0 place-items-center rounded-[3px] border border-control-border bg-background"
469
+ >
470
+ <LinkIcon class="size-4 text-foreground-faint" aria-hidden="true" />
471
+ </div>
472
+ <div>
473
+ <h2 class="text-[13.5px] font-medium text-foreground">
474
+ {runtime.i18n.t("uploads:pasteURL")}
475
+ </h2>
476
+ <p class="mt-0.5 text-[11.5px] leading-4 text-foreground-faint">
477
+ {runtime.i18n.t("uploads:remoteImportDescription")}
478
+ </p>
479
+ </div>
480
+ </div>
481
+ <label class="grid gap-1.5 text-[11px] text-foreground-sub" for="remote-upload-url">
482
+ <span>{runtime.i18n.t("uploads:assetURL")}</span>
483
+ <Input
484
+ id="remote-upload-url"
485
+ type="url"
486
+ placeholder="https://cdn.example.com/image.jpg"
487
+ value={remoteURL}
488
+ oninput={(event) => updateRemoteURL(event.currentTarget.value)}
489
+ disabled={!uploadEnabled || !canCreate || remotePending}
490
+ required
491
+ />
492
+ </label>
493
+ {#if metadataFields.length > 0}
494
+ <UploadMetadataFields
495
+ fields={metadataFields}
496
+ values={remoteValues}
497
+ issues={[...remoteIssues]}
498
+ idPrefix="remote-upload"
499
+ disabled={!uploadEnabled || !canCreate || remotePending}
500
+ labelPrefix={runtime.i18n.t("uploads:urlImportMetadata")}
501
+ onValueChange={updateRemoteMetadata}
502
+ />
503
+ {/if}
504
+ {#if remoteError !== undefined}<Banner tone="destructive">{remoteError}</Banner>{/if}
505
+ <div class="flex flex-wrap items-center justify-between gap-3">
506
+ {#if remoteDocumentID !== undefined}
507
+ <Link
508
+ class={buttonVariants({ variant: "ghost", size: "sm" })}
509
+ to={localizedPath(documentPath(slug, remoteDocumentID))}
510
+ >
511
+ {runtime.i18n.t("uploads:openImportedAsset")}
512
+ </Link>
513
+ {:else}<span></span>{/if}
514
+ <Button
515
+ type="submit"
516
+ disabled={!uploadEnabled ||
517
+ !canCreate ||
518
+ remotePending ||
519
+ remoteOutcomeUncertain ||
520
+ remoteURL.trim() === ""}
521
+ >
522
+ <LinkIcon class="size-3.5" aria-hidden="true" />
523
+ {remotePending
524
+ ? runtime.i18n.t("uploads:importing")
525
+ : runtime.i18n.t("uploads:importFromURL")}
526
+ </Button>
527
+ </div>
528
+ </form>
529
+
530
+ {#if queue.length === 0}
531
+ <div
532
+ class="mt-7 rounded-[4px] border border-control-border px-5 py-8 text-center text-[12.5px] text-foreground-faint"
533
+ >
534
+ {runtime.i18n.t("uploads:noFilesQueued")}
535
+ </div>
536
+ {:else}
537
+ <ul
538
+ class="mt-7 grid gap-3"
539
+ aria-label={runtime.i18n.t("uploads:uploadQueue")}
540
+ aria-busy={running}
541
+ >
542
+ {#each queue as item (item.id)}
543
+ <li
544
+ class="rounded-[4px] border border-control-border bg-control p-4"
545
+ data-upload-row
546
+ tabindex="-1"
547
+ >
548
+ <div class="flex items-start gap-4">
549
+ <div
550
+ class="grid size-10 shrink-0 place-items-center rounded-[3px] border border-control-border bg-background"
551
+ >
552
+ {#if item.status === "complete"}<CheckIcon
553
+ class="size-4 text-success"
554
+ />{:else}<FileUpIcon class="size-4 text-foreground-faint" />{/if}
555
+ </div>
556
+ <div class="min-w-0 flex-1">
557
+ <div class="flex flex-wrap items-start justify-between gap-2">
558
+ <div class="min-w-0">
559
+ <p class="truncate text-[13px] font-medium text-foreground">{item.file.name}</p>
560
+ <p class="font-mono mt-1 text-[9.5px] text-foreground-faint">
561
+ {formatBytes(item.file.size)} · {statusLabel(item.status)}
562
+ </p>
563
+ </div>
564
+ <div class="flex items-center gap-1">
565
+ {#if item.status === "complete" && item.documentID !== undefined}
566
+ <Link
567
+ class={buttonVariants({ variant: "ghost", size: "xs" })}
568
+ to={localizedPath(documentPath(slug, item.documentID))}
569
+ data-upload-result-action
570
+ >
571
+ {runtime.i18n.t("uploads:openAsset")}
572
+ </Link>
573
+ {:else if item.status === "failed"}
574
+ <Button
575
+ variant="ghost"
576
+ size="icon-xs"
577
+ onclick={(event) => retry(item, event.currentTarget)}
578
+ aria-label={runtime.i18n.t("uploads:retryFilename", {
579
+ filename: item.file.name,
580
+ })}
581
+ data-upload-result-action
582
+ disabled={running}
583
+ >
584
+ <RotateCcwIcon />
585
+ </Button>
586
+ {/if}
587
+ {#if item.status === "queued" || item.status === "failed"}
588
+ <Button
589
+ variant="ghost"
590
+ size="icon-xs"
591
+ onclick={() => removeItem(item.id)}
592
+ aria-label={runtime.i18n.t("uploads:removeFilename", {
593
+ filename: item.file.name,
594
+ })}
595
+ disabled={running}
596
+ >
597
+ <Trash2Icon />
598
+ </Button>
599
+ {/if}
600
+ </div>
601
+ </div>
602
+
603
+ {#if metadataFields.length > 0}
604
+ <div class="mt-4">
605
+ <UploadMetadataFields
606
+ fields={metadataFields}
607
+ values={item.values}
608
+ issues={[...item.issues]}
609
+ idPrefix={item.id}
610
+ disabled={item.status === "uploading" || item.status === "complete"}
611
+ onValueChange={(field, value) => updateItemMetadata(item, field, value)}
612
+ />
613
+ </div>
614
+ {/if}
615
+ {#if item.error !== undefined}<p class="mt-3 text-[11.5px] text-destructive">
616
+ {item.error}
617
+ </p>{/if}
618
+ </div>
619
+ </div>
620
+ </li>
621
+ {/each}
622
+ </ul>
623
+ {/if}
624
+ </section>
@@ -0,0 +1,39 @@
1
+ import type { SchemaField } from "@riducms/protocol";
2
+
3
+ import { initialFormValues } from "@admin/core/forms/form-schema";
4
+
5
+ export function bulkUploadMetadataFields(fields: readonly SchemaField[]) {
6
+ return fields.filter(
7
+ (field) =>
8
+ field.category === "scalar" &&
9
+ field.admin.readOnly !== true &&
10
+ (field.type === "text" ||
11
+ field.type === "textarea" ||
12
+ (field.type === "select" && field.select?.hasMany !== true))
13
+ );
14
+ }
15
+
16
+ export function initialBulkUploadValues(
17
+ filename: string,
18
+ fields: readonly SchemaField[],
19
+ useAsTitle: string | undefined
20
+ ) {
21
+ const stem = filename
22
+ .replace(/\.[^.]+$/, "")
23
+ .replaceAll(/[-_]+/g, " ")
24
+ .trim();
25
+ const defaults = initialFormValues(fields);
26
+ const values: Record<string, string> = {};
27
+ for (const field of fields) {
28
+ const defaultValue = defaults[field.name];
29
+ values[field.name] = typeof defaultValue === "string" ? defaultValue : "";
30
+ if (field.name === "alt" || (field.required && field.name === useAsTitle)) {
31
+ values[field.name] = stem;
32
+ }
33
+ }
34
+ return values;
35
+ }
36
+
37
+ export function uploadData(values: Readonly<Record<string, string>>) {
38
+ return Object.fromEntries(Object.entries(values).filter(([, value]) => value.trim().length > 0));
39
+ }