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