@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,1252 @@
1
+ import type { DocumentLockEnvelope, SchemaCollection } from "@riducms/protocol";
2
+ import { RiduError } from "@riducms/sdk";
3
+ import { tick, untrack } from "svelte";
4
+
5
+ import type { AdminDocument, AdminVersion } from "@admin/core/api/admin-client";
6
+ import { FormController, FormValidationError } from "@admin/core/forms/form-controller.svelte";
7
+ import {
8
+ clearFormDraft,
9
+ saveFormDraft,
10
+ takeFormDraft,
11
+ } from "@admin/core/forms/form-draft-recovery";
12
+ import { documentFormValues, initialFormValues } from "@admin/core/forms/form-schema";
13
+ import { invalidFieldLabels } from "@admin/core/forms/form-validation";
14
+ import type { NotificationCenter } from "@admin/core/notifications/notification-center.svelte";
15
+ import {
16
+ collectionPath,
17
+ createDocumentPath,
18
+ documentPath,
19
+ globalPath,
20
+ } from "@admin/core/routing/admin-paths";
21
+ import type { AdminRuntime } from "@admin/core/runtime/admin-runtime.svelte";
22
+ import { documentHeadingField } from "@admin/features/documents/document-heading";
23
+
24
+ type Navigate = (to: string, options?: { replace?: boolean }) => void;
25
+
26
+ interface DocumentControllerOptions {
27
+ runtime: AdminRuntime;
28
+ notifications: NotificationCenter;
29
+ navigate: Navigate;
30
+ get slug(): string;
31
+ get documentID(): string | undefined;
32
+ get global(): boolean;
33
+ get view(): "edit" | "api";
34
+ get locale(): string | undefined;
35
+ }
36
+
37
+ const uploadMetadataFields = new Set([
38
+ "filename",
39
+ "mimeType",
40
+ "filesize",
41
+ "url",
42
+ "objectKey",
43
+ "width",
44
+ "height",
45
+ "sizes",
46
+ "focalX",
47
+ "focalY",
48
+ "cropX",
49
+ "cropY",
50
+ "cropWidth",
51
+ "cropHeight",
52
+ ]);
53
+
54
+ export class DocumentController {
55
+ readonly form: FormController;
56
+ collection = $state.raw<SchemaCollection>();
57
+ collectionAvailable = $state(true);
58
+ loading = $state(true);
59
+ error = $state<string>();
60
+ selectedFiles = $state<FileList>();
61
+ currentRevision = $state(0);
62
+ currentStatus = $state<"draft" | "published">("published");
63
+ versions = $state.raw<AdminVersion[]>([]);
64
+ versionOperation = $state(false);
65
+ imageOperation = $state(false);
66
+ imageOutcomeUncertain = $state(false);
67
+ saveOutcomeUncertain = $state(false);
68
+ duplicateOperation = $state(false);
69
+ deleteDialogOpen = $state(false);
70
+ restoreDialogOpen = $state(false);
71
+ pendingRestore = $state.raw<AdminVersion>();
72
+ currentDocument = $state.raw<AdminDocument>();
73
+ documentView = $state("edit");
74
+ copiedAssetURL = $state(false);
75
+ documentLock = $state.raw<DocumentLockEnvelope>();
76
+ lockOperation = $state(false);
77
+ #activeRouteKey = "";
78
+ #activeContentLocale?: string;
79
+ #appliedManifestRevision = 0;
80
+ #copyResetTimer?: number;
81
+ #lockRefreshTimer?: number;
82
+ #lockRoute?: { collection: string; documentID: string };
83
+ #saveRequest?: AbortController;
84
+ #imageRequest?: AbortController;
85
+
86
+ constructor(readonly options: DocumentControllerOptions) {
87
+ this.form = new FormController({}, options.runtime.i18n);
88
+ $effect(() => {
89
+ const slug = this.options.slug;
90
+ const documentID = this.options.global ? slug : this.options.documentID;
91
+ const locale = this.options.locale;
92
+ // Route setup reads the manifest inside an untracked controller operation, so manifest
93
+ // readiness must remain an explicit synchronization dependency for direct lazy-route loads.
94
+ if (this.options.runtime.manifestRevision === 0) return;
95
+ return untrack(() => this.#enterRoute(slug, documentID, locale));
96
+ });
97
+
98
+ $effect(() => {
99
+ const view = this.options.view;
100
+ untrack(() => {
101
+ if (view === "api") this.documentView = "api";
102
+ else if (this.documentView === "api") this.documentView = "edit";
103
+ });
104
+ });
105
+
106
+ $effect(() => {
107
+ const revision = this.options.runtime.manifestRevision;
108
+ if (revision === 0 || revision === this.#appliedManifestRevision) return;
109
+ if (this.#appliedManifestRevision === 0) {
110
+ this.#appliedManifestRevision = revision;
111
+ return;
112
+ }
113
+ this.#appliedManifestRevision = revision;
114
+ untrack(() => this.#applySchemaUpdate());
115
+ });
116
+
117
+ $effect(() => {
118
+ const checkpoint = () => this.checkpointDraft();
119
+ const leavePage = () => {
120
+ checkpoint();
121
+ this.#releaseLock();
122
+ };
123
+ window.addEventListener("pagehide", leavePage);
124
+ const hot = import.meta.hot;
125
+ hot?.on("vite:beforeFullReload", checkpoint);
126
+ return () => {
127
+ window.removeEventListener("pagehide", leavePage);
128
+ hot?.off("vite:beforeFullReload", checkpoint);
129
+ };
130
+ });
131
+
132
+ $effect(() => {
133
+ const interval = this.collection?.versionSettings?.autosaveIntervalSeconds ?? 0;
134
+ const documentID = this.documentID;
135
+ if (documentID === undefined || interval <= 0) return;
136
+
137
+ const timer = window.setInterval(async () => {
138
+ if (!this.canSave || this.versionOperation) return;
139
+ // A background update to a published document would make its edits public.
140
+ // Keep a recoverable local checkpoint until the author explicitly chooses
141
+ // Publish changes; draft documents can use the ordinary server mutation.
142
+ if (this.currentStatus === "published") this.checkpointDraft();
143
+ else await this.save({ silent: true });
144
+ }, interval * 1_000);
145
+ return () => window.clearInterval(timer);
146
+ });
147
+
148
+ $effect(() => () => {
149
+ this.#saveRequest?.abort();
150
+ this.#imageRequest?.abort();
151
+ if (this.#copyResetTimer !== undefined) window.clearTimeout(this.#copyResetTimer);
152
+ if (this.#lockRefreshTimer !== undefined) window.clearInterval(this.#lockRefreshTimer);
153
+ this.#releaseLock();
154
+ });
155
+ }
156
+
157
+ get documentID() {
158
+ return this.options.global ? this.options.slug : this.options.documentID;
159
+ }
160
+
161
+ get creating() {
162
+ return !this.options.global && this.documentID === undefined;
163
+ }
164
+
165
+ get globalResource() {
166
+ return this.options.global;
167
+ }
168
+
169
+ get collectionSlug() {
170
+ return this.collection?.slug ?? this.options.slug;
171
+ }
172
+
173
+ get contentLocale() {
174
+ return this.#activeRouteKey === "" ? this.options.locale : this.#activeContentLocale;
175
+ }
176
+
177
+ get uploadCollection() {
178
+ return this.collection?.capabilities.upload === true;
179
+ }
180
+
181
+ get versionedCollection() {
182
+ return this.collection?.capabilities.versions === true;
183
+ }
184
+
185
+ get draftsCollection() {
186
+ return this.collection?.versionSettings?.drafts === true;
187
+ }
188
+
189
+ get selectedFile() {
190
+ return this.selectedFiles?.item(0) ?? undefined;
191
+ }
192
+
193
+ get hasUnsavedChanges() {
194
+ return this.form.dirty || (this.creating && this.selectedFile !== undefined);
195
+ }
196
+
197
+ get canSave() {
198
+ const operationAllowed = this.creating
199
+ ? this.form.access?.operations.create === true
200
+ : this.form.access?.operations.update === true;
201
+ const publicationAllowed =
202
+ !this.creating || !this.versionedCollection || this.draftsCollection || this.canPublish;
203
+ const inputReady = this.creating
204
+ ? !this.uploadCollection || this.selectedFile !== undefined
205
+ : this.hasUnsavedChanges;
206
+ return (
207
+ this.collectionAvailable &&
208
+ !this.saveOutcomeUncertain &&
209
+ !this.imageOutcomeUncertain &&
210
+ !this.imageOperation &&
211
+ !this.lockedByAnotherEditor &&
212
+ operationAllowed &&
213
+ publicationAllowed &&
214
+ !this.form.submitting &&
215
+ inputReady
216
+ );
217
+ }
218
+
219
+ get lockedByAnotherEditor() {
220
+ return this.documentLock?.lock !== null && this.documentLock?.owned === false;
221
+ }
222
+
223
+ get lockOwnerLabel() {
224
+ return this.documentLock?.lock?.ownerLabel;
225
+ }
226
+
227
+ get lockUpdatedAt() {
228
+ return this.documentLock?.lock?.updatedAt;
229
+ }
230
+
231
+ get canTakeOverLock() {
232
+ return this.lockedByAnotherEditor && this.documentLock?.canTakeOver === true;
233
+ }
234
+
235
+ get headingField() {
236
+ return documentHeadingField(this.collection, (path) => this.form.canRead(path));
237
+ }
238
+
239
+ get titleField() {
240
+ if (this.globalResource) return undefined;
241
+ return this.headingField?.type === "text" && this.headingField.admin.row === undefined
242
+ ? this.headingField
243
+ : undefined;
244
+ }
245
+
246
+ get documentFields() {
247
+ return (this.collection?.fields ?? []).filter(
248
+ (field) =>
249
+ this.form.canRead(field.path) &&
250
+ !(this.uploadCollection && uploadMetadataFields.has(field.name))
251
+ );
252
+ }
253
+
254
+ get canDelete() {
255
+ return !this.lockedByAnotherEditor && this.form.access?.operations.delete === true;
256
+ }
257
+
258
+ get canDuplicate() {
259
+ return !this.lockedByAnotherEditor && this.form.access?.operations.duplicate === true;
260
+ }
261
+
262
+ get canPublish() {
263
+ return !this.lockedByAnotherEditor && this.form.access?.operations.publish === true;
264
+ }
265
+
266
+ get canUnpublish() {
267
+ return !this.lockedByAnotherEditor && this.form.access?.operations.unpublish === true;
268
+ }
269
+
270
+ get canRestoreVersion() {
271
+ if (this.lockedByAnotherEditor || this.form.access?.operations.update !== true) return false;
272
+ const status = this.pendingRestore?.Status;
273
+ return status === "draft"
274
+ ? this.form.access?.operations.unpublish === true
275
+ : this.form.access?.operations.publish === true;
276
+ }
277
+
278
+ get canReadVersions() {
279
+ return !this.creating && this.form.access?.operations.readVersions === true;
280
+ }
281
+
282
+ get canEditImage() {
283
+ return (
284
+ this.uploadCollection &&
285
+ !this.creating &&
286
+ !this.lockedByAnotherEditor &&
287
+ !this.form.submitting &&
288
+ !this.imageOutcomeUncertain &&
289
+ this.assetMimeType?.startsWith("image/") === true &&
290
+ this.form.access?.operations.update === true &&
291
+ (!this.versionedCollection ||
292
+ this.currentStatus !== "published" ||
293
+ this.form.access?.operations.publish === true)
294
+ );
295
+ }
296
+
297
+ get validationFields() {
298
+ return (this.collection?.fields ?? []).filter(
299
+ (field) => !(this.uploadCollection && uploadMetadataFields.has(field.name))
300
+ );
301
+ }
302
+
303
+ get collectionSingularLabel() {
304
+ const labels = this.collection?.labels;
305
+ return labels === undefined
306
+ ? this.options.runtime.i18n.t("documents:document")
307
+ : this.options.runtime.i18n.text(labels.singular, labels.singularTranslations);
308
+ }
309
+
310
+ get documentHeading() {
311
+ if (this.globalResource) return this.collectionSingularLabel;
312
+ if (this.uploadCollection) {
313
+ const filename = this.currentDocument?.filename;
314
+ if (typeof filename === "string" && filename.trim().length > 0) return filename;
315
+ if (this.selectedFile !== undefined) return this.selectedFile.name;
316
+ return this.creating
317
+ ? this.options.runtime.i18n.t("documents:newLabel", {
318
+ label: this.collectionSingularLabel.toLocaleLowerCase(
319
+ this.options.runtime.i18n.language
320
+ ),
321
+ })
322
+ : this.options.runtime.i18n.t("documents:untitledAsset");
323
+ }
324
+ const value =
325
+ this.headingField === undefined ? undefined : this.form.get(this.headingField.path);
326
+ const title = typeof value === "string" ? value.trim() : "";
327
+ if (title.length > 0) return title;
328
+ return this.creating
329
+ ? this.options.runtime.i18n.t("documents:newLabel", {
330
+ label: this.collectionSingularLabel.toLocaleLowerCase(this.options.runtime.i18n.language),
331
+ })
332
+ : this.options.runtime.i18n.t("documents:untitled");
333
+ }
334
+
335
+ get assetURL() {
336
+ return this.#documentString("url");
337
+ }
338
+
339
+ get assetFilename() {
340
+ return this.#documentString("filename") ?? this.documentHeading;
341
+ }
342
+
343
+ get assetMimeType() {
344
+ return this.#documentString("mimeType");
345
+ }
346
+
347
+ get assetSize() {
348
+ const value = this.#documentNumber("filesize");
349
+ if (value === undefined) return undefined;
350
+ if (value < 1_024)
351
+ return this.options.runtime.i18n.formatNumber(value, { style: "unit", unit: "byte" });
352
+ if (value < 1_048_576)
353
+ return this.options.runtime.i18n.formatNumber(value / 1_024, {
354
+ maximumFractionDigits: 0,
355
+ style: "unit",
356
+ unit: "kilobyte",
357
+ });
358
+ return this.options.runtime.i18n.formatNumber(value / 1_048_576, {
359
+ maximumFractionDigits: value < 10_485_760 ? 1 : 0,
360
+ style: "unit",
361
+ unit: "megabyte",
362
+ });
363
+ }
364
+
365
+ get assetDimensions() {
366
+ const width = this.#documentNumber("width");
367
+ const height = this.#documentNumber("height");
368
+ return width !== undefined && height !== undefined
369
+ ? `${this.options.runtime.i18n.formatNumber(width)} × ${this.options.runtime.i18n.formatNumber(height)}`
370
+ : undefined;
371
+ }
372
+
373
+ revealFirstIssue = () => {
374
+ const issue = this.form.issues[0];
375
+ if (issue === undefined) return undefined;
376
+ this.documentView = "edit";
377
+ return issue.path;
378
+ };
379
+
380
+ checkpointDraft = () => {
381
+ const collection = this.collection;
382
+ if (collection === undefined) return;
383
+ if (this.form.dirty) {
384
+ saveFormDraft(
385
+ collection,
386
+ this.documentID,
387
+ $state.snapshot(this.form.values),
388
+ $state.snapshot(this.form.original)
389
+ );
390
+ } else clearFormDraft(collection.id, this.documentID);
391
+ };
392
+
393
+ discardChanges = () => {
394
+ const collection = this.collection;
395
+ if (collection !== undefined) clearFormDraft(collection.id, this.documentID);
396
+ this.form.reset($state.snapshot(this.form.original));
397
+ this.selectedFiles = undefined;
398
+ };
399
+
400
+ refresh = async () => {
401
+ const documentID = this.documentID;
402
+ if (documentID === undefined || this.collection === undefined) return;
403
+ const request = new AbortController();
404
+ await this.#load(this.collection.slug, documentID, request.signal);
405
+ };
406
+
407
+ takeOverLock = async () => {
408
+ const documentID = this.documentID;
409
+ if (
410
+ documentID === undefined ||
411
+ this.collection?.capabilities.locking !== true ||
412
+ !this.canTakeOverLock
413
+ )
414
+ return false;
415
+ this.lockOperation = true;
416
+ try {
417
+ const state = await this.options.runtime.client.acquireDocumentLock(
418
+ this.collectionSlug,
419
+ documentID,
420
+ true
421
+ );
422
+ this.#applyLock(state);
423
+ this.options.notifications.success({
424
+ title: this.options.runtime.i18n.t("documents:lockTakenOver"),
425
+ });
426
+ return state.owned;
427
+ } catch (cause) {
428
+ this.options.notifications.error({
429
+ title: this.options.runtime.i18n.t("documents:lockTakeoverFailed"),
430
+ message: cause instanceof Error ? cause.message : undefined,
431
+ });
432
+ return false;
433
+ } finally {
434
+ this.lockOperation = false;
435
+ }
436
+ };
437
+
438
+ updateUploadImage = async (input: {
439
+ focalX: number;
440
+ focalY: number;
441
+ cropX: number;
442
+ cropY: number;
443
+ cropWidth: number;
444
+ cropHeight: number;
445
+ }) => {
446
+ const documentID = this.documentID;
447
+ if (documentID === undefined || !this.canEditImage || this.imageOperation) return false;
448
+ const routeKey = this.#activeRouteKey;
449
+ const collectionSlug = this.collectionSlug;
450
+ const request = new AbortController();
451
+ this.#imageRequest?.abort();
452
+ this.#imageRequest = request;
453
+ this.imageOperation = true;
454
+ try {
455
+ const updated = await this.options.runtime.client.updateUploadImage(
456
+ collectionSlug,
457
+ documentID,
458
+ input,
459
+ { revision: this.currentRevision, signal: request.signal }
460
+ );
461
+ if (request.signal.aborted || this.#activeRouteKey !== routeKey) return false;
462
+ this.#applyImageDocument(updated);
463
+ this.imageOutcomeUncertain = false;
464
+ this.options.runtime.documentsChanged();
465
+ this.options.notifications.success({
466
+ title: this.options.runtime.i18n.t("uploads:imageSizesRegenerated"),
467
+ message:
468
+ input.cropWidth > 0
469
+ ? this.options.runtime.i18n.t("uploads:cropAndFocalUpdated", {
470
+ cropWidth: this.options.runtime.i18n.formatNumber(Math.round(input.cropWidth)),
471
+ cropHeight: this.options.runtime.i18n.formatNumber(Math.round(input.cropHeight)),
472
+ focalX: this.options.runtime.i18n.formatNumber(Math.round(input.focalX)),
473
+ focalY: this.options.runtime.i18n.formatNumber(Math.round(input.focalY)),
474
+ })
475
+ : this.options.runtime.i18n.t("uploads:focalUpdated", {
476
+ focalX: this.options.runtime.i18n.formatNumber(Math.round(input.focalX)),
477
+ focalY: this.options.runtime.i18n.formatNumber(Math.round(input.focalY)),
478
+ }),
479
+ });
480
+ await Promise.allSettled([
481
+ this.#refreshVersions(documentID),
482
+ this.#refreshAccess(documentID),
483
+ ]);
484
+ return true;
485
+ } catch (cause) {
486
+ if (request.signal.aborted || this.#activeRouteKey !== routeKey) return false;
487
+ const confirmedFailure = cause instanceof RiduError && cause.status < 500;
488
+ if (!confirmedFailure) {
489
+ try {
490
+ const refreshed = await this.options.runtime.client.find(collectionSlug, documentID, {
491
+ signal: request.signal,
492
+ locale: this.contentLocale,
493
+ });
494
+ if (request.signal.aborted || this.#activeRouteKey !== routeKey) return false;
495
+ this.#applyImageDocument(refreshed);
496
+ this.imageOutcomeUncertain = false;
497
+ await Promise.allSettled([
498
+ this.#refreshVersions(documentID),
499
+ this.#refreshAccess(documentID),
500
+ ]);
501
+ if (imageEditMatches(refreshed, input)) {
502
+ this.options.notifications.success({
503
+ title: this.options.runtime.i18n.t("uploads:imageSizesRegenerated"),
504
+ message: this.options.runtime.i18n.t("uploads:imageStateRecovered"),
505
+ });
506
+ return true;
507
+ }
508
+ this.options.notifications.error({
509
+ title: this.options.runtime.i18n.t("uploads:imageSizesNotRegenerated"),
510
+ message: this.options.runtime.i18n.t("uploads:serverStateRefreshed"),
511
+ });
512
+ return false;
513
+ } catch {
514
+ if (request.signal.aborted || this.#activeRouteKey !== routeKey) return false;
515
+ this.imageOutcomeUncertain = true;
516
+ this.options.notifications.error({
517
+ title: this.options.runtime.i18n.t("uploads:imageOutcomeUnknown"),
518
+ message: this.options.runtime.i18n.t("uploads:imageOutcomeUnknownDescription"),
519
+ });
520
+ return false;
521
+ }
522
+ }
523
+ this.options.notifications.error({
524
+ title: this.options.runtime.i18n.t("uploads:imageSizesNotRegenerated"),
525
+ message:
526
+ cause instanceof Error
527
+ ? cause.message
528
+ : this.options.runtime.i18n.t("uploads:focalUpdateFailed"),
529
+ });
530
+ return false;
531
+ } finally {
532
+ if (this.#imageRequest === request) {
533
+ this.#imageRequest = undefined;
534
+ this.imageOperation = false;
535
+ }
536
+ }
537
+ };
538
+
539
+ save = async ({
540
+ silent = false,
541
+ password,
542
+ publish = false,
543
+ }: { silent?: boolean; password?: string; publish?: boolean } = {}) => {
544
+ if (!this.canSave) return false;
545
+ const publishingChanges =
546
+ publish && !this.creating && this.versionedCollection && this.currentStatus === "published";
547
+ if (publishingChanges && !this.canPublish) return false;
548
+ const creatingUpload = this.creating && this.uploadCollection;
549
+ const routeKey = this.#activeRouteKey;
550
+ const collectionSlug = this.collectionSlug;
551
+ const documentID = this.documentID;
552
+ const request = new AbortController();
553
+ this.#saveRequest?.abort();
554
+ this.#saveRequest = request;
555
+ try {
556
+ const wasCreating = this.creating;
557
+ const saved = await this.form.submit(this.validationFields, this.creating, async (values) => {
558
+ if (this.globalResource) {
559
+ if (publishingChanges) {
560
+ return this.options.runtime.client.publishGlobalChanges(collectionSlug, values, {
561
+ revision: this.currentRevision,
562
+ signal: request.signal,
563
+ locale: this.contentLocale,
564
+ });
565
+ }
566
+ return this.options.runtime.client.updateGlobal(collectionSlug, values, {
567
+ revision: this.currentRevision,
568
+ signal: request.signal,
569
+ locale: this.contentLocale,
570
+ });
571
+ }
572
+ if (documentID !== undefined) {
573
+ if (publishingChanges) {
574
+ return this.options.runtime.client.publishChanges(collectionSlug, documentID, values, {
575
+ revision: this.currentRevision,
576
+ signal: request.signal,
577
+ locale: this.contentLocale,
578
+ });
579
+ }
580
+ return this.options.runtime.client.update(collectionSlug, documentID, values, {
581
+ revision: this.currentRevision,
582
+ signal: request.signal,
583
+ locale: this.contentLocale,
584
+ });
585
+ }
586
+ if (this.collection?.capabilities.auth) {
587
+ if (password === undefined)
588
+ throw new Error(this.options.runtime.i18n.t("documents:enterNewAccountPassword"));
589
+ return this.options.runtime.client.createAuthUser(collectionSlug, values, password, {
590
+ signal: request.signal,
591
+ locale: this.contentLocale,
592
+ });
593
+ }
594
+ if (!this.uploadCollection) {
595
+ return this.options.runtime.client.create(collectionSlug, values, {
596
+ signal: request.signal,
597
+ locale: this.contentLocale,
598
+ ...(this.versionedCollection
599
+ ? { draft: this.draftsCollection ? !publish : false }
600
+ : {}),
601
+ });
602
+ }
603
+ if (this.selectedFile === undefined)
604
+ throw new Error(this.options.runtime.i18n.t("uploads:chooseFileBeforeSaving"));
605
+ return this.options.runtime.client.upload(collectionSlug, this.selectedFile, {
606
+ data: values,
607
+ signal: request.signal,
608
+ locale: this.contentLocale,
609
+ });
610
+ });
611
+ if (request.signal.aborted || this.#activeRouteKey !== routeKey) return false;
612
+ this.saveOutcomeUncertain = false;
613
+ this.#applyDocument(saved);
614
+ if (creatingUpload) this.selectedFiles = undefined;
615
+ this.options.runtime.documentsChanged();
616
+ clearFormDraft(this.collection?.id ?? this.collectionSlug, this.documentID);
617
+ if (!silent) {
618
+ this.options.notifications.success({
619
+ title: wasCreating
620
+ ? this.options.runtime.i18n.t("documents:createdLabel", {
621
+ label: this.collectionSingularLabel,
622
+ })
623
+ : publishingChanges
624
+ ? this.options.runtime.i18n.t("documents:published")
625
+ : this.options.runtime.i18n.t("documents:updated"),
626
+ });
627
+ }
628
+ if (wasCreating) {
629
+ // Let the route clear any blocked departure now that the successful save has
630
+ // removed both form and upload dirtiness before replacing the create URL.
631
+ await tick();
632
+ this.options.navigate(
633
+ withContentLocale(documentPath(this.collectionSlug, saved.id), this.contentLocale),
634
+ { replace: true }
635
+ );
636
+ } else {
637
+ await Promise.allSettled([this.#refreshVersions(saved.id), this.#refreshAccess(saved.id)]);
638
+ }
639
+ return true;
640
+ } catch (cause) {
641
+ if (request.signal.aborted || this.#activeRouteKey !== routeKey) return false;
642
+ if (
643
+ creatingUpload &&
644
+ !(cause instanceof FormValidationError) &&
645
+ (!(cause instanceof RiduError) || cause.status >= 500)
646
+ ) {
647
+ this.saveOutcomeUncertain = true;
648
+ if (!silent) {
649
+ this.options.notifications.error({
650
+ title: this.options.runtime.i18n.t("uploads:outcomeUnknown"),
651
+ message: this.options.runtime.i18n.t("uploads:outcomeUnknownDescription"),
652
+ });
653
+ }
654
+ return false;
655
+ }
656
+ if (!silent) {
657
+ const validationFailure =
658
+ this.form.issues.length > 0 &&
659
+ (cause instanceof FormValidationError ||
660
+ (cause instanceof RiduError && cause.code === "validation"));
661
+ if (validationFailure) {
662
+ this.options.notifications.validation({
663
+ labels: invalidFieldLabels(
664
+ this.validationFields,
665
+ this.form.issues,
666
+ this.options.runtime.i18n
667
+ ),
668
+ });
669
+ } else {
670
+ this.options.notifications.error({
671
+ title: this.options.runtime.i18n.t("documents:notSaved"),
672
+ message:
673
+ cause instanceof Error
674
+ ? cause.message
675
+ : this.options.runtime.i18n.t("documents:saveFailed"),
676
+ });
677
+ }
678
+ }
679
+ return false;
680
+ } finally {
681
+ if (this.#saveRequest === request) this.#saveRequest = undefined;
682
+ }
683
+ };
684
+
685
+ changePublication = async (next: "draft" | "published") => {
686
+ if (this.documentID === undefined || this.lockedByAnotherEditor) return;
687
+ this.versionOperation = true;
688
+ try {
689
+ const saved =
690
+ next === "published"
691
+ ? this.globalResource
692
+ ? await this.options.runtime.client.publishGlobal(this.collectionSlug, {
693
+ revision: this.currentRevision,
694
+ locale: this.contentLocale,
695
+ })
696
+ : await this.options.runtime.client.publish(this.collectionSlug, this.documentID, {
697
+ revision: this.currentRevision,
698
+ locale: this.contentLocale,
699
+ })
700
+ : this.globalResource
701
+ ? await this.options.runtime.client.unpublishGlobal(this.collectionSlug, {
702
+ revision: this.currentRevision,
703
+ locale: this.contentLocale,
704
+ })
705
+ : await this.options.runtime.client.unpublish(this.collectionSlug, this.documentID, {
706
+ revision: this.currentRevision,
707
+ locale: this.contentLocale,
708
+ });
709
+ this.#applyDocument(saved);
710
+ this.options.runtime.documentsChanged();
711
+ await Promise.allSettled([
712
+ this.#refreshVersions(this.documentID),
713
+ this.#refreshAccess(this.documentID),
714
+ ]);
715
+ this.options.notifications.success({
716
+ title: this.options.runtime.i18n.t(
717
+ next === "published" ? "documents:publishedTitle" : "documents:unpublishedTitle"
718
+ ),
719
+ message: this.options.runtime.i18n.t("documents:statusNow", {
720
+ status: this.options.runtime.i18n.t(
721
+ next === "published" ? "documents:published" : "documents:draft"
722
+ ),
723
+ }),
724
+ });
725
+ } catch (cause) {
726
+ this.options.notifications.error({
727
+ title: this.options.runtime.i18n.t("documents:statusNotChanged"),
728
+ message:
729
+ cause instanceof Error
730
+ ? cause.message
731
+ : this.options.runtime.i18n.t("documents:statusChangeFailed"),
732
+ });
733
+ } finally {
734
+ this.versionOperation = false;
735
+ }
736
+ };
737
+
738
+ duplicate = async () => {
739
+ if (this.documentID === undefined || this.globalResource || this.lockedByAnotherEditor) return;
740
+ this.duplicateOperation = true;
741
+ try {
742
+ const duplicated = await this.options.runtime.client.duplicate(
743
+ this.collectionSlug,
744
+ this.documentID,
745
+ {},
746
+ { locale: this.contentLocale }
747
+ );
748
+ this.options.runtime.documentsChanged();
749
+ this.options.notifications.success({
750
+ title: this.options.runtime.i18n.t("documents:duplicatedLabel", {
751
+ label: this.collectionSingularLabel,
752
+ }),
753
+ message: this.options.runtime.i18n.t("documents:copyReady"),
754
+ });
755
+ this.options.navigate(
756
+ withContentLocale(documentPath(this.collectionSlug, duplicated.id), this.contentLocale)
757
+ );
758
+ } catch (cause) {
759
+ this.options.notifications.error({
760
+ title: this.options.runtime.i18n.t("documents:notDuplicated"),
761
+ message:
762
+ cause instanceof Error
763
+ ? cause.message
764
+ : this.options.runtime.i18n.t("documents:duplicateFailed"),
765
+ });
766
+ } finally {
767
+ this.duplicateOperation = false;
768
+ }
769
+ };
770
+
771
+ restore = async (version: AdminVersion) => {
772
+ if (this.documentID === undefined || this.lockedByAnotherEditor || !this.canRestoreVersion)
773
+ return;
774
+ this.versionOperation = true;
775
+ try {
776
+ const saved = this.globalResource
777
+ ? await this.options.runtime.client.restoreGlobal(this.collectionSlug, version.Revision, {
778
+ revision: this.currentRevision,
779
+ locale: this.contentLocale,
780
+ })
781
+ : await this.options.runtime.client.restore(
782
+ this.collectionSlug,
783
+ this.documentID,
784
+ version.Revision,
785
+ { revision: this.currentRevision, locale: this.contentLocale }
786
+ );
787
+ this.#applyDocument(saved);
788
+ this.options.runtime.documentsChanged();
789
+ await Promise.allSettled([
790
+ this.#refreshVersions(this.documentID),
791
+ this.#refreshAccess(this.documentID),
792
+ ]);
793
+ this.options.notifications.success({
794
+ title: this.options.runtime.i18n.t("versions:revisionRestoredTitle"),
795
+ message: this.options.runtime.i18n.t("versions:revisionIsCurrent", {
796
+ revision: this.options.runtime.i18n.formatNumber(version.Revision),
797
+ }),
798
+ });
799
+ } catch (cause) {
800
+ this.options.notifications.error({
801
+ title: this.options.runtime.i18n.t("versions:revisionNotRestored"),
802
+ message:
803
+ cause instanceof Error
804
+ ? cause.message
805
+ : this.options.runtime.i18n.t("versions:revisionRestoreFailed"),
806
+ });
807
+ } finally {
808
+ this.versionOperation = false;
809
+ this.restoreDialogOpen = false;
810
+ this.pendingRestore = undefined;
811
+ }
812
+ };
813
+
814
+ remove = async () => {
815
+ if (this.documentID === undefined || this.lockedByAnotherEditor) return;
816
+ try {
817
+ await this.options.runtime.client.delete(this.collectionSlug, this.documentID);
818
+ this.options.runtime.documentsChanged();
819
+ this.deleteDialogOpen = false;
820
+ this.options.notifications.success({
821
+ title: this.options.runtime.i18n.t(
822
+ this.collection?.capabilities.trash === true
823
+ ? "documents:movedToTrashLabel"
824
+ : "documents:deletedLabel",
825
+ { label: this.collectionSingularLabel }
826
+ ),
827
+ message:
828
+ this.collection?.capabilities.trash === true
829
+ ? this.options.runtime.i18n.t("documents:restoreFromTrash")
830
+ : this.options.runtime.i18n.t("documents:deletedSuccessfully"),
831
+ });
832
+ this.options.navigate(collectionPath(this.collectionSlug));
833
+ } catch (cause) {
834
+ this.options.notifications.error({
835
+ title: this.options.runtime.i18n.t("documents:notDeleted"),
836
+ message:
837
+ cause instanceof Error
838
+ ? cause.message
839
+ : this.options.runtime.i18n.t("documents:deleteFailed"),
840
+ });
841
+ }
842
+ };
843
+
844
+ requestRestore = (version: AdminVersion) => {
845
+ this.pendingRestore = version;
846
+ this.restoreDialogOpen = true;
847
+ };
848
+
849
+ versionDate = (version: AdminVersion) => this.#formatDate(version.CreatedAt);
850
+
851
+ documentDate = (value: string | undefined) =>
852
+ value === undefined ? "—" : this.#formatDate(value);
853
+
854
+ copyAssetURL = async () => {
855
+ if (this.assetURL === undefined) return;
856
+ await navigator.clipboard.writeText(new URL(this.assetURL, window.location.origin).href);
857
+ this.copiedAssetURL = true;
858
+ this.options.notifications.success({
859
+ title: this.options.runtime.i18n.t("uploads:urlCopied"),
860
+ message: this.options.runtime.i18n.t("uploads:urlCopiedDescription"),
861
+ });
862
+ if (this.#copyResetTimer !== undefined) window.clearTimeout(this.#copyResetTimer);
863
+ this.#copyResetTimer = window.setTimeout(() => {
864
+ this.copiedAssetURL = false;
865
+ this.#copyResetTimer = undefined;
866
+ }, 1_500);
867
+ };
868
+
869
+ #enterRoute(slug: string, documentID: string | undefined, locale: string | undefined) {
870
+ const resourceKey = `${this.options.global ? "global" : "collection"}:${slug}:${documentID ?? "new"}`;
871
+ const routeKey = `${resourceKey}:${locale ?? "default"}`;
872
+ if (
873
+ this.#activeRouteKey.startsWith(`${resourceKey}:`) &&
874
+ this.#activeRouteKey !== routeKey &&
875
+ this.hasUnsavedChanges
876
+ ) {
877
+ return;
878
+ }
879
+ this.#activeContentLocale = locale;
880
+ if (this.#activeRouteKey !== "" && this.#activeRouteKey !== routeKey) {
881
+ this.#saveRequest?.abort();
882
+ this.#saveRequest = undefined;
883
+ this.#imageRequest?.abort();
884
+ this.#imageRequest = undefined;
885
+ this.imageOperation = false;
886
+ }
887
+ const collection = (
888
+ this.options.global
889
+ ? this.options.runtime.manifest?.globals
890
+ : this.options.runtime.manifest?.collections
891
+ )?.find((item) => item.slug === slug);
892
+ if (collection === undefined) {
893
+ this.#activeRouteKey = routeKey;
894
+ this.#releaseLock();
895
+ this.collection = undefined;
896
+ this.collectionAvailable = false;
897
+ this.currentDocument = undefined;
898
+ this.selectedFiles = undefined;
899
+ this.versions = [];
900
+ this.form.reset({});
901
+ this.form.setResource({ collection: slug, id: documentID, global: this.options.global });
902
+ this.loading = false;
903
+ this.error = this.options.runtime.i18n.t("documents:collectionUnavailable");
904
+ this.form.setAccess(undefined, documentID === undefined ? "create" : "update");
905
+ return;
906
+ }
907
+ this.collection = collection;
908
+ this.form.setResource({
909
+ collection: collection.slug,
910
+ id: documentID,
911
+ global: this.options.global,
912
+ });
913
+ if (
914
+ this.#lockRoute !== undefined &&
915
+ (this.#lockRoute.collection !== collection.slug || this.#lockRoute.documentID !== documentID)
916
+ ) {
917
+ this.#releaseLock();
918
+ }
919
+ this.collectionAvailable = true;
920
+ if (this.#activeRouteKey === routeKey) {
921
+ if (!this.loading || documentID === undefined) return;
922
+ const request = new AbortController();
923
+ this.#load(collection.slug, documentID, request.signal);
924
+ return () => request.abort();
925
+ }
926
+
927
+ this.#activeRouteKey = routeKey;
928
+ this.saveOutcomeUncertain = false;
929
+ this.imageOutcomeUncertain = false;
930
+ this.form.setAccess(undefined, documentID === undefined ? "create" : "update");
931
+ if (documentID === undefined) {
932
+ this.form.reset(initialFormValues(collection.fields));
933
+ this.form.setLocalization(locale);
934
+ this.versions = [];
935
+ this.currentDocument = undefined;
936
+ this.currentRevision = 0;
937
+ this.currentStatus = this.draftsCollection ? "draft" : "published";
938
+ this.documentLock = undefined;
939
+ this.form.writeBlocked = false;
940
+ this.#restoreDraft();
941
+ const request = new AbortController();
942
+ this.#loadCreateAccess(collection.slug, request.signal);
943
+ return () => request.abort();
944
+ }
945
+ const request = new AbortController();
946
+ this.#load(collection.slug, documentID, request.signal);
947
+ return () => request.abort();
948
+ }
949
+
950
+ #applySchemaUpdate() {
951
+ const previous = this.collection;
952
+ if (previous === undefined) return;
953
+ const next = (
954
+ this.globalResource
955
+ ? this.options.runtime.manifest?.globals
956
+ : this.options.runtime.manifest?.collections
957
+ )?.find((item) => item.id === previous.id);
958
+ if (next === undefined) {
959
+ this.collectionAvailable = false;
960
+ this.options.notifications.error({
961
+ title: this.options.runtime.i18n.t("documents:collectionRemoved"),
962
+ });
963
+ return;
964
+ }
965
+
966
+ const result = this.form.reconcile(previous.fields, next.fields, this.creating);
967
+ this.collection = next;
968
+ this.collectionAvailable = true;
969
+ if (result.detached.length > 0) {
970
+ this.options.notifications.warning({
971
+ title: this.options.runtime.i18n.t("documents:schemaUpdatedWithDraft"),
972
+ message: this.options.runtime.i18n.t("documents:incompatibleDraftValues", {
973
+ count: result.detached.length,
974
+ }),
975
+ });
976
+ }
977
+ if (previous.slug !== next.slug) {
978
+ this.options.navigate(
979
+ withContentLocale(
980
+ this.globalResource
981
+ ? globalPath(next.slug)
982
+ : this.documentID === undefined
983
+ ? createDocumentPath(next.slug)
984
+ : documentPath(next.slug, this.documentID),
985
+ this.contentLocale
986
+ ),
987
+ { replace: true }
988
+ );
989
+ }
990
+ }
991
+
992
+ #restoreDraft() {
993
+ const collection = this.collection;
994
+ if (collection === undefined) return;
995
+ const checkpoint = takeFormDraft(collection.id, this.documentID);
996
+ if (checkpoint === undefined) return;
997
+ const result = this.form.recover(
998
+ { values: checkpoint.values, original: checkpoint.original },
999
+ checkpoint.collection.fields,
1000
+ collection.fields
1001
+ );
1002
+ const title =
1003
+ result.restoredFields === 0
1004
+ ? this.options.runtime.i18n.t("documents:noDraftFieldsRestored")
1005
+ : this.options.runtime.i18n.t("documents:draftFieldsRestored", {
1006
+ count: result.restoredFields,
1007
+ });
1008
+ const message =
1009
+ result.detached.length === 0
1010
+ ? undefined
1011
+ : this.options.runtime.i18n.t("documents:incompatibleDraftValues", {
1012
+ count: result.detached.length,
1013
+ });
1014
+ if (result.restoredFields === 0 || result.detached.length > 0)
1015
+ this.options.notifications.warning({ title, message });
1016
+ else this.options.notifications.success({ title });
1017
+ }
1018
+
1019
+ async #load(slug: string, documentID: string, signal: AbortSignal) {
1020
+ this.loading = true;
1021
+ this.error = undefined;
1022
+ try {
1023
+ const [document, access] = await Promise.all([
1024
+ this.globalResource
1025
+ ? this.options.runtime.client.global(slug, {
1026
+ signal,
1027
+ locale: this.contentLocale,
1028
+ })
1029
+ : this.options.runtime.client.find(slug, documentID, {
1030
+ signal,
1031
+ locale: this.contentLocale,
1032
+ }),
1033
+ this.globalResource
1034
+ ? this.options.runtime.client.globalAccess(slug, {
1035
+ signal,
1036
+ locale: this.contentLocale,
1037
+ })
1038
+ : this.options.runtime.client.collectionAccess(slug, {
1039
+ id: documentID,
1040
+ signal,
1041
+ locale: this.contentLocale,
1042
+ }),
1043
+ ]);
1044
+ this.form.setAccess(access, "update");
1045
+ this.#applyDocument(document, true);
1046
+ if (this.versionedCollection && access.operations.readVersions) {
1047
+ this.versions = this.globalResource
1048
+ ? await this.options.runtime.client.globalVersions(slug, {
1049
+ signal,
1050
+ locale: this.contentLocale,
1051
+ })
1052
+ : await this.options.runtime.client.versions(slug, documentID, {
1053
+ signal,
1054
+ locale: this.contentLocale,
1055
+ });
1056
+ } else {
1057
+ this.versions = [];
1058
+ }
1059
+ if (!this.globalResource && this.collection?.capabilities.locking === true) {
1060
+ await this.#acquireLock(slug, documentID, signal);
1061
+ } else {
1062
+ this.documentLock = undefined;
1063
+ this.form.writeBlocked = false;
1064
+ }
1065
+ } catch (cause) {
1066
+ if (signal.aborted) return;
1067
+ this.error =
1068
+ cause instanceof Error
1069
+ ? cause.message
1070
+ : this.options.runtime.i18n.t("documents:loadFailed");
1071
+ } finally {
1072
+ if (!signal.aborted) this.loading = false;
1073
+ }
1074
+ }
1075
+
1076
+ async #acquireLock(slug: string, documentID: string, signal?: AbortSignal) {
1077
+ const state = await this.options.runtime.client.acquireDocumentLock(slug, documentID, false, {
1078
+ signal,
1079
+ });
1080
+ if (signal?.aborted) return;
1081
+ this.#lockRoute = { collection: slug, documentID };
1082
+ this.#applyLock(state);
1083
+ if (state.owned) this.#startLockRefresh(slug, documentID);
1084
+ }
1085
+
1086
+ #applyLock(state: DocumentLockEnvelope) {
1087
+ this.documentLock = state;
1088
+ this.form.writeBlocked = state.lock !== null && !state.owned;
1089
+ if (state.owned && this.#lockRoute !== undefined) {
1090
+ this.#startLockRefresh(this.#lockRoute.collection, this.#lockRoute.documentID);
1091
+ }
1092
+ }
1093
+
1094
+ #startLockRefresh(collection: string, documentID: string) {
1095
+ if (this.#lockRefreshTimer !== undefined) window.clearInterval(this.#lockRefreshTimer);
1096
+ const interval = Math.max(
1097
+ 5,
1098
+ Math.floor((this.collection?.documentLockSettings?.durationSeconds ?? 120) / 3)
1099
+ );
1100
+ this.#lockRefreshTimer = window.setInterval(async () => {
1101
+ try {
1102
+ const state = await this.options.runtime.client.acquireDocumentLock(collection, documentID);
1103
+ this.#applyLock(state);
1104
+ } catch {
1105
+ // A failed heartbeat is reflected by the next explicit request or save conflict.
1106
+ }
1107
+ }, interval * 1_000);
1108
+ }
1109
+
1110
+ #releaseLock() {
1111
+ if (this.#lockRefreshTimer !== undefined) {
1112
+ window.clearInterval(this.#lockRefreshTimer);
1113
+ this.#lockRefreshTimer = undefined;
1114
+ }
1115
+ const route = this.#lockRoute;
1116
+ const owned = this.documentLock?.owned === true;
1117
+ this.#lockRoute = undefined;
1118
+ this.documentLock = undefined;
1119
+ this.form.writeBlocked = false;
1120
+ if (route !== undefined && owned) {
1121
+ this.options.runtime.client
1122
+ .releaseDocumentLock(route.collection, route.documentID, { keepalive: true })
1123
+ .catch(() => {});
1124
+ }
1125
+ }
1126
+
1127
+ async #loadCreateAccess(slug: string, signal: AbortSignal) {
1128
+ this.loading = true;
1129
+ this.error = undefined;
1130
+ try {
1131
+ const access = await this.options.runtime.client.collectionAccess(slug, {
1132
+ data: $state.snapshot(this.form.values),
1133
+ signal,
1134
+ locale: this.contentLocale,
1135
+ });
1136
+ if (!signal.aborted) this.form.setAccess(access, "create");
1137
+ } catch (cause) {
1138
+ if (!signal.aborted) {
1139
+ this.error =
1140
+ cause instanceof Error
1141
+ ? cause.message
1142
+ : this.options.runtime.i18n.t("documents:accessEvaluationFailed");
1143
+ }
1144
+ } finally {
1145
+ if (!signal.aborted) this.loading = false;
1146
+ }
1147
+ }
1148
+
1149
+ async #refreshAccess(documentID: string) {
1150
+ const routeKey = this.#activeRouteKey;
1151
+ const collectionSlug = this.collectionSlug;
1152
+ const access = this.globalResource
1153
+ ? await this.options.runtime.client.globalAccess(collectionSlug, {
1154
+ locale: this.contentLocale,
1155
+ })
1156
+ : await this.options.runtime.client.collectionAccess(collectionSlug, {
1157
+ id: documentID,
1158
+ locale: this.contentLocale,
1159
+ });
1160
+ if (this.#activeRouteKey === routeKey) this.form.setAccess(access, "update");
1161
+ }
1162
+
1163
+ async #refreshVersions(documentID: string) {
1164
+ const routeKey = this.#activeRouteKey;
1165
+ const collectionSlug = this.collectionSlug;
1166
+ if (this.versionedCollection && this.canReadVersions) {
1167
+ const versions = this.globalResource
1168
+ ? await this.options.runtime.client.globalVersions(collectionSlug, {
1169
+ locale: this.contentLocale,
1170
+ })
1171
+ : await this.options.runtime.client.versions(collectionSlug, documentID, {
1172
+ locale: this.contentLocale,
1173
+ });
1174
+ if (this.#activeRouteKey === routeKey) this.versions = versions;
1175
+ } else {
1176
+ if (this.#activeRouteKey === routeKey) this.versions = [];
1177
+ }
1178
+ }
1179
+
1180
+ #applyDocument(document: AdminDocument, recoverDraft = false) {
1181
+ this.currentDocument = document;
1182
+ this.form.reset(documentFormValues(this.collection?.fields ?? [], document));
1183
+ this.form.setLocalization(this.contentLocale, document._localization?.sources);
1184
+ this.currentRevision = typeof document._revision === "number" ? document._revision : 0;
1185
+ this.currentStatus = document._status === "draft" ? "draft" : "published";
1186
+ if (recoverDraft) this.#restoreDraft();
1187
+ }
1188
+
1189
+ #applyImageDocument(document: AdminDocument) {
1190
+ this.currentDocument = document;
1191
+ this.form.setLocalization(this.contentLocale, document._localization?.sources);
1192
+ for (const name of uploadMetadataFields) {
1193
+ this.form.values[name] = document[name];
1194
+ this.form.original[name] = document[name];
1195
+ }
1196
+ this.currentRevision = typeof document._revision === "number" ? document._revision : 0;
1197
+ this.currentStatus = document._status === "draft" ? "draft" : "published";
1198
+ }
1199
+
1200
+ #documentString(name: string) {
1201
+ const value = this.currentDocument?.[name];
1202
+ return typeof value === "string" && value.length > 0 ? value : undefined;
1203
+ }
1204
+
1205
+ #documentNumber(name: string) {
1206
+ const value = this.currentDocument?.[name];
1207
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
1208
+ }
1209
+
1210
+ #formatDate(value: string) {
1211
+ const date = new Date(value);
1212
+ if (Number.isNaN(date.valueOf())) return value;
1213
+ return this.options.runtime.i18n.formatDate(date, {
1214
+ day: "2-digit",
1215
+ month: "short",
1216
+ year: "numeric",
1217
+ });
1218
+ }
1219
+
1220
+ #fieldForIssue(path: string) {
1221
+ return this.documentFields.find(
1222
+ (field) => path === field.path || path.startsWith(`${field.name}.`)
1223
+ );
1224
+ }
1225
+ }
1226
+
1227
+ function imageEditMatches(
1228
+ document: AdminDocument,
1229
+ input: {
1230
+ focalX: number;
1231
+ focalY: number;
1232
+ cropX: number;
1233
+ cropY: number;
1234
+ cropWidth: number;
1235
+ cropHeight: number;
1236
+ }
1237
+ ) {
1238
+ return (
1239
+ document.focalX === input.focalX &&
1240
+ document.focalY === input.focalY &&
1241
+ document.cropX === input.cropX &&
1242
+ document.cropY === input.cropY &&
1243
+ document.cropWidth === input.cropWidth &&
1244
+ document.cropHeight === input.cropHeight
1245
+ );
1246
+ }
1247
+
1248
+ function withContentLocale(path: string, locale: string | undefined) {
1249
+ if (locale === undefined) return path;
1250
+ const search = new URLSearchParams({ locale });
1251
+ return `${path}?${search.toString()}`;
1252
+ }