@riducms/admin 0.2.3 → 0.4.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 +29 -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 +131 -135
  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 +159 -0
  120. package/src/features/{auth → account}/account-security-route.svelte +140 -42
  121. package/src/features/api-reference/api-reference-code.svelte +114 -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 +30 -60
  133. package/src/features/auth/create-first-user-route.svelte +83 -122
  134. package/src/features/auth/forgot-password-route.svelte +32 -24
  135. package/src/features/auth/login-route.svelte +69 -117
  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 +28 -23
  139. package/src/features/auth/reset-password-route.svelte +111 -41
  140. package/src/features/auth/verify-email-route.svelte +52 -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 +531 -617
  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 +336 -239
  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 +148 -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,122 @@ 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
398
+ await runtime.client.auth.forceUnlock(
399
+ { collection: this.collection.slug, id: this.currentDocument.id },
400
+ { signal: request.signal }
424
401
  );
425
- this.#applyLock(state);
426
- this.options.notifications.success({
427
- title: this.options.runtime.i18n.t("documents:lockTakenOver"),
428
- });
429
- return state.owned;
402
+ if (request.signal.aborted) return;
403
+ notifications.success({ title: runtime.i18n.t("documents:accountUnlocked") });
430
404
  } catch (cause) {
431
- this.options.notifications.error({
432
- title: this.options.runtime.i18n.t("documents:lockTakeoverFailed"),
405
+ if (request.signal.aborted) return;
406
+ notifications.error({
407
+ title: runtime.i18n.t("documents:accountUnlockFailed"),
433
408
  message: cause instanceof Error ? cause.message : undefined,
434
409
  });
435
- return false;
436
410
  } finally {
437
- this.lockOperation = false;
411
+ if (!request.signal.aborted) {
412
+ this.#forceUnlockRequest = undefined;
413
+ this.unlockOperation = false;
414
+ }
438
415
  }
439
416
  };
440
417
 
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;
418
+ copyFromLocale = async (source: string) => {
419
+ const { runtime, notifications } = this.options;
420
+ if (
421
+ this.contentLocale === undefined ||
422
+ source === this.contentLocale ||
423
+ this.currentDocument === undefined ||
424
+ this.copyLocaleOperation
425
+ )
426
+ return;
453
427
  const request = new AbortController();
454
- this.#imageRequest?.abort();
455
- this.#imageRequest = request;
456
- this.imageOperation = true;
428
+ const destinationLocale = this.contentLocale;
429
+ const document = this.currentDocument;
430
+ this.#copyLocaleRequest?.abort();
431
+ this.#copyLocaleRequest = request;
432
+ this.copyLocaleOperation = true;
457
433
  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
- }),
434
+ if (this.globalResource) {
435
+ await runtime.client.copyGlobalLocale(
436
+ this.collectionSlug,
437
+ { from: source, to: destinationLocale },
438
+ { revision: document._revision, signal: request.signal }
439
+ );
440
+ } else {
441
+ await runtime.client.copyLocale(
442
+ this.collectionSlug,
443
+ document.id,
444
+ { from: source, to: destinationLocale },
445
+ { revision: document._revision, signal: request.signal }
446
+ );
447
+ }
448
+ if (request.signal.aborted) return;
449
+ await this.refresh();
450
+ if (request.signal.aborted) return;
451
+ runtime.documentsChanged();
452
+ notifications.success({
453
+ title: runtime.i18n.t("documents:localeCopied"),
454
+ message: runtime.i18n.t("documents:localeCopiedDescription", {
455
+ source,
456
+ destination: destinationLocale,
457
+ }),
482
458
  });
483
- await Promise.allSettled([
484
- this.#refreshVersions(documentID),
485
- this.#refreshAccess(documentID),
486
- ]);
487
- return true;
488
459
  } 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"),
460
+ if (request.signal.aborted) return;
461
+ notifications.error({
462
+ title: runtime.i18n.t("documents:localeNotCopied"),
528
463
  message:
529
- cause instanceof Error
530
- ? cause.message
531
- : this.options.runtime.i18n.t("uploads:focalUpdateFailed"),
464
+ cause instanceof Error ? cause.message : runtime.i18n.t("documents:localeCopyFailed"),
532
465
  });
533
- return false;
534
466
  } finally {
535
- if (this.#imageRequest === request) {
536
- this.#imageRequest = undefined;
537
- this.imageOperation = false;
467
+ if (!request.signal.aborted) {
468
+ this.#copyLocaleRequest = undefined;
469
+ this.copyLocaleOperation = false;
538
470
  }
539
471
  }
540
472
  };
541
473
 
474
+ refresh = async () => {
475
+ const documentID = this.documentID;
476
+ if (
477
+ documentID === undefined ||
478
+ this.collection === undefined ||
479
+ this.#saveRequest !== undefined ||
480
+ this.upload.busy ||
481
+ this.publicationOperation
482
+ )
483
+ return;
484
+ await this.#load(this.collection.slug, documentID);
485
+ };
486
+
542
487
  save = async ({
543
488
  silent = false,
544
489
  password,
545
490
  publish = false,
546
491
  }: { silent?: boolean; password?: string; publish?: boolean } = {}) => {
547
492
  if (!this.canSave) return false;
548
- const publishingChanges =
549
- publish && !this.creating && this.versionedCollection && this.currentStatus === "published";
493
+ const publishingChanges = publish && !this.creating && this.versionedCollection;
550
494
  if (publishingChanges && !this.canPublish) return false;
551
- const creatingUpload = this.creating && this.uploadCollection;
552
- const routeKey = this.#activeRouteKey;
553
495
  const collectionSlug = this.collectionSlug;
554
496
  const documentID = this.documentID;
555
497
  const request = new AbortController();
556
498
  this.#saveRequest?.abort();
557
499
  this.#saveRequest = request;
500
+ this.#cancelLoad();
501
+ const wasCreating = this.creating;
558
502
  try {
559
- const wasCreating = this.creating;
560
503
  const saved = await this.form.submit(this.validationFields, this.creating, async (values) => {
561
504
  if (this.globalResource) {
562
505
  if (publishingChanges) {
@@ -573,6 +516,20 @@ export class DocumentController {
573
516
  });
574
517
  }
575
518
  if (documentID !== undefined) {
519
+ if (this.uploadCollection) {
520
+ return this.options.runtime.client.updateUpload(
521
+ collectionSlug,
522
+ documentID,
523
+ {
524
+ data: values,
525
+ file: this.upload.file,
526
+ filename: this.upload.filename,
527
+ image: this.upload.image,
528
+ publish: publishingChanges,
529
+ },
530
+ { revision: this.currentRevision, signal: request.signal, locale: this.contentLocale }
531
+ );
532
+ }
576
533
  if (publishingChanges) {
577
534
  return this.options.runtime.client.publishChanges(collectionSlug, documentID, values, {
578
535
  revision: this.currentRevision,
@@ -589,10 +546,10 @@ export class DocumentController {
589
546
  if (this.collection?.capabilities.auth) {
590
547
  if (password === undefined)
591
548
  throw new Error(this.options.runtime.i18n.t("documents:enterNewAccountPassword"));
592
- return this.options.runtime.client.createAuthUser(collectionSlug, values, password, {
593
- signal: request.signal,
594
- locale: this.contentLocale,
595
- });
549
+ return this.options.runtime.client.auth.createUser(
550
+ { collection: collectionSlug, data: values, password },
551
+ { signal: request.signal, locale: this.contentLocale }
552
+ );
596
553
  }
597
554
  if (!this.uploadCollection) {
598
555
  return this.options.runtime.client.create(collectionSlug, values, {
@@ -606,15 +563,18 @@ export class DocumentController {
606
563
  if (this.selectedFile === undefined)
607
564
  throw new Error(this.options.runtime.i18n.t("uploads:chooseFileBeforeSaving"));
608
565
  return this.options.runtime.client.upload(collectionSlug, this.selectedFile, {
566
+ filename: this.upload.filename,
567
+ image: this.upload.image,
568
+ publish,
609
569
  data: values,
610
570
  signal: request.signal,
611
571
  locale: this.contentLocale,
612
572
  });
613
573
  });
614
- if (request.signal.aborted || this.#activeRouteKey !== routeKey) return false;
574
+ if (request.signal.aborted) return false;
615
575
  this.saveOutcomeUncertain = false;
616
576
  this.#applyDocument(saved);
617
- if (creatingUpload) this.selectedFiles = undefined;
577
+ this.lastSavedAt = Date.now();
618
578
  this.options.runtime.documentsChanged();
619
579
  clearFormDraft(this.collection?.id ?? this.collectionSlug, this.documentID);
620
580
  if (!silent) {
@@ -632,18 +592,19 @@ export class DocumentController {
632
592
  // Let the route clear any blocked departure now that the successful save has
633
593
  // removed both form and upload dirtiness before replacing the create URL.
634
594
  await tick();
595
+ if (request.signal.aborted) return false;
635
596
  this.options.navigate(
636
597
  withContentLocale(documentPath(this.collectionSlug, saved.id), this.contentLocale),
637
598
  { replace: true }
638
599
  );
639
600
  } else {
640
- await Promise.allSettled([this.#refreshVersions(saved.id), this.#refreshAccess(saved.id)]);
601
+ await this.#refreshAccess(saved.id);
641
602
  }
642
- return true;
603
+ return !request.signal.aborted;
643
604
  } catch (cause) {
644
- if (request.signal.aborted || this.#activeRouteKey !== routeKey) return false;
605
+ if (request.signal.aborted) return false;
645
606
  if (
646
- creatingUpload &&
607
+ this.uploadCollection &&
647
608
  !(cause instanceof FormValidationError) &&
648
609
  (!(cause instanceof RiduError) || cause.status >= 500)
649
610
  ) {
@@ -651,7 +612,11 @@ export class DocumentController {
651
612
  if (!silent) {
652
613
  this.options.notifications.error({
653
614
  title: this.options.runtime.i18n.t("uploads:outcomeUnknown"),
654
- message: this.options.runtime.i18n.t("uploads:outcomeUnknownDescription"),
615
+ message: this.options.runtime.i18n.t(
616
+ wasCreating
617
+ ? "uploads:outcomeUnknownDescription"
618
+ : "uploads:saveOutcomeUnknownDescription"
619
+ ),
655
620
  });
656
621
  }
657
622
  return false;
@@ -686,8 +651,19 @@ export class DocumentController {
686
651
  };
687
652
 
688
653
  changePublication = async (next: "draft" | "published") => {
689
- if (this.documentID === undefined || this.lockedByAnotherEditor) return;
690
- this.versionOperation = true;
654
+ const allowed = next === "published" ? this.canPublish : this.canUnpublish;
655
+ if (
656
+ this.documentID === undefined ||
657
+ !allowed ||
658
+ this.form.submitting ||
659
+ this.publicationOperation ||
660
+ this.upload.busy ||
661
+ this.saveOutcomeUncertain
662
+ )
663
+ return;
664
+ this.#cancelLoad();
665
+ this.publicationOperation = true;
666
+ const generation = this.#routeGeneration;
691
667
  try {
692
668
  const saved =
693
669
  next === "published"
@@ -709,12 +685,12 @@ export class DocumentController {
709
685
  revision: this.currentRevision,
710
686
  locale: this.contentLocale,
711
687
  });
688
+ if (generation !== this.#routeGeneration) return;
712
689
  this.#applyDocument(saved);
690
+ this.lastSavedAt = Date.now();
713
691
  this.options.runtime.documentsChanged();
714
- await Promise.allSettled([
715
- this.#refreshVersions(this.documentID),
716
- this.#refreshAccess(this.documentID),
717
- ]);
692
+ await this.#refreshAccess(this.documentID);
693
+ if (generation !== this.#routeGeneration) return;
718
694
  this.options.notifications.success({
719
695
  title: this.options.runtime.i18n.t(
720
696
  next === "published" ? "documents:publishedTitle" : "documents:unpublishedTitle"
@@ -726,6 +702,7 @@ export class DocumentController {
726
702
  }),
727
703
  });
728
704
  } catch (cause) {
705
+ if (generation !== this.#routeGeneration) return;
729
706
  this.options.notifications.error({
730
707
  title: this.options.runtime.i18n.t("documents:statusNotChanged"),
731
708
  message:
@@ -734,13 +711,15 @@ export class DocumentController {
734
711
  : this.options.runtime.i18n.t("documents:statusChangeFailed"),
735
712
  });
736
713
  } finally {
737
- this.versionOperation = false;
714
+ if (generation === this.#routeGeneration) this.publicationOperation = false;
738
715
  }
739
716
  };
740
717
 
741
718
  duplicate = async () => {
742
- if (this.documentID === undefined || this.globalResource || this.lockedByAnotherEditor) return;
719
+ if (this.documentID === undefined || this.globalResource || this.lock.lockedByAnotherEditor)
720
+ return;
743
721
  this.duplicateOperation = true;
722
+ const generation = this.#routeGeneration;
744
723
  try {
745
724
  const duplicated = await this.options.runtime.client.duplicate(
746
725
  this.collectionSlug,
@@ -748,6 +727,7 @@ export class DocumentController {
748
727
  {},
749
728
  { locale: this.contentLocale }
750
729
  );
730
+ if (generation !== this.#routeGeneration) return;
751
731
  this.options.runtime.documentsChanged();
752
732
  this.options.notifications.success({
753
733
  title: this.options.runtime.i18n.t("documents:duplicatedLabel", {
@@ -759,6 +739,7 @@ export class DocumentController {
759
739
  withContentLocale(documentPath(this.collectionSlug, duplicated.id), this.contentLocale)
760
740
  );
761
741
  } catch (cause) {
742
+ if (generation !== this.#routeGeneration) return;
762
743
  this.options.notifications.error({
763
744
  title: this.options.runtime.i18n.t("documents:notDuplicated"),
764
745
  message:
@@ -767,57 +748,17 @@ export class DocumentController {
767
748
  : this.options.runtime.i18n.t("documents:duplicateFailed"),
768
749
  });
769
750
  } 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;
751
+ if (generation === this.#routeGeneration) this.duplicateOperation = false;
814
752
  }
815
753
  };
816
754
 
817
755
  remove = async () => {
818
- if (this.documentID === undefined || this.lockedByAnotherEditor) return;
756
+ if (this.documentID === undefined || this.lock.lockedByAnotherEditor) return;
757
+ this.#cancelLoad();
758
+ const generation = this.#routeGeneration;
819
759
  try {
820
760
  await this.options.runtime.client.delete(this.collectionSlug, this.documentID);
761
+ if (generation !== this.#routeGeneration) return;
821
762
  this.options.runtime.documentsChanged();
822
763
  this.deleteDialogOpen = false;
823
764
  this.options.notifications.success({
@@ -834,6 +775,7 @@ export class DocumentController {
834
775
  });
835
776
  this.options.navigate(collectionPath(this.collectionSlug));
836
777
  } catch (cause) {
778
+ if (generation !== this.#routeGeneration) return;
837
779
  this.options.notifications.error({
838
780
  title: this.options.runtime.i18n.t("documents:notDeleted"),
839
781
  message:
@@ -844,64 +786,81 @@ export class DocumentController {
844
786
  }
845
787
  };
846
788
 
847
- requestRestore = (version: AdminVersion) => {
848
- this.pendingRestore = version;
849
- this.restoreDialogOpen = true;
850
- };
851
-
852
- versionDate = (version: AdminVersion) => this.#formatDate(version.CreatedAt);
853
-
854
789
  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
- };
790
+ value === undefined ? "—" : formatAdminDateTime(value, this.options.runtime.i18n);
791
+
792
+ #cancelRouteRequests() {
793
+ this.#forceUnlockRequest?.abort();
794
+ this.#copyLocaleRequest?.abort();
795
+ this.#forceUnlockRequest = undefined;
796
+ this.#copyLocaleRequest = undefined;
797
+ this.unlockOperation = false;
798
+ this.copyLocaleOperation = false;
799
+ }
871
800
 
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"}`;
801
+ #enterRoute(
802
+ slug: string,
803
+ documentID: string | undefined,
804
+ locale: string | undefined,
805
+ manifestRevision: number,
806
+ global: boolean,
807
+ editable: boolean,
808
+ prepared?: AdminDocumentDataV1 | AdminCreateDataV1
809
+ ) {
810
+ const resourceKey = `${global ? "global" : "collection"}:${slug}:${documentID ?? "new"}`;
811
+ const ownerKey = `${resourceKey}:${locale ?? "default"}`;
812
+ const routeKey = `${ownerKey}:${editable ? "edit" : "read"}`;
813
+ const sameOwner = this.#activeRouteKey.startsWith(`${ownerKey}:`);
875
814
  if (
876
815
  this.#activeRouteKey.startsWith(`${resourceKey}:`) &&
877
816
  this.#activeRouteKey !== routeKey &&
878
817
  this.hasUnsavedChanges
879
818
  ) {
819
+ if (manifestRevision !== this.#appliedManifestRevision) {
820
+ this.#appliedManifestRevision = manifestRevision;
821
+ this.#applySchemaUpdate();
822
+ }
880
823
  return;
881
824
  }
825
+ if (this.#activeRouteKey === routeKey && this.collection !== undefined) {
826
+ if (manifestRevision !== this.#appliedManifestRevision) {
827
+ this.#appliedManifestRevision = manifestRevision;
828
+ this.#applySchemaUpdate();
829
+ } else if (prepared !== undefined && !this.hasUnsavedChanges) {
830
+ this.#load(slug, documentID, false, prepared);
831
+ }
832
+ return;
833
+ }
834
+ this.#appliedManifestRevision = manifestRevision;
835
+ if (this.#activeRouteKey !== routeKey && !sameOwner) {
836
+ // A later visit to the same URL is a different owner for mutation completions.
837
+ this.#routeGeneration += 1;
838
+ this.#cancelRouteRequests();
839
+ this.publicationOperation = false;
840
+ this.duplicateOperation = false;
841
+ this.deleteDialogOpen = false;
842
+ this.currentDocument = undefined;
843
+ this.lastSavedAt = undefined;
844
+ this.upload.reset(this.currentDocument);
845
+ }
846
+ this.#cancelLoad();
882
847
  this.#activeContentLocale = locale;
883
- if (this.#activeRouteKey !== "" && this.#activeRouteKey !== routeKey) {
848
+ if (this.#activeRouteKey !== "" && this.#activeRouteKey !== routeKey && !sameOwner) {
884
849
  this.#saveRequest?.abort();
885
850
  this.#saveRequest = undefined;
886
- this.#imageRequest?.abort();
887
- this.#imageRequest = undefined;
888
- this.imageOperation = false;
889
851
  }
890
852
  const collection = (
891
- this.options.global
892
- ? this.options.runtime.manifest?.globals
893
- : this.options.runtime.manifest?.collections
853
+ global ? this.options.runtime.manifest?.globals : this.options.runtime.manifest?.collections
894
854
  )?.find((item) => item.slug === slug);
895
855
  if (collection === undefined) {
896
856
  this.#activeRouteKey = routeKey;
897
- this.#releaseLock();
857
+ this.lock.release();
898
858
  this.collection = undefined;
899
859
  this.collectionAvailable = false;
900
860
  this.currentDocument = undefined;
901
- this.selectedFiles = undefined;
902
- this.versions = [];
861
+ this.upload.reset(this.currentDocument);
903
862
  this.form.reset({});
904
- this.form.setResource({ collection: slug, id: documentID, global: this.options.global });
863
+ this.form.setResource({ collection: slug, id: documentID, global });
905
864
  this.loading = false;
906
865
  this.error = this.options.runtime.i18n.t("documents:collectionUnavailable");
907
866
  this.form.setAccess(undefined, documentID === undefined ? "create" : "update");
@@ -911,43 +870,24 @@ export class DocumentController {
911
870
  this.form.setResource({
912
871
  collection: collection.slug,
913
872
  id: documentID,
914
- global: this.options.global,
873
+ global,
915
874
  });
916
- if (
917
- this.#lockRoute !== undefined &&
918
- (this.#lockRoute.collection !== collection.slug || this.#lockRoute.documentID !== documentID)
919
- ) {
920
- this.#releaseLock();
921
- }
875
+ this.lock.releaseUnless(collection.slug, documentID);
876
+ if (!editable) this.lock.release();
922
877
  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
878
  this.#activeRouteKey = routeKey;
931
879
  this.saveOutcomeUncertain = false;
932
- this.imageOutcomeUncertain = false;
933
880
  this.form.setAccess(undefined, documentID === undefined ? "create" : "update");
934
881
  if (documentID === undefined) {
935
882
  this.form.reset(initialFormValues(collection.fields), collection.fields);
936
883
  this.form.setLocalization(locale);
937
- this.versions = [];
938
884
  this.currentDocument = undefined;
939
- this.currentRevision = 0;
940
- this.currentStatus = this.draftsCollection ? "draft" : "published";
941
- this.documentLock = undefined;
942
- this.form.writeBlocked = false;
885
+ this.lock.release();
943
886
  this.#restoreDraft();
944
- const request = new AbortController();
945
- this.#loadCreateAccess(collection.slug, request.signal);
946
- return () => request.abort();
887
+ this.#load(collection.slug, undefined, false, prepared);
888
+ return;
947
889
  }
948
- const request = new AbortController();
949
- this.#load(collection.slug, documentID, request.signal);
950
- return () => request.abort();
890
+ this.#load(collection.slug, documentID, false, prepared);
951
891
  }
952
892
 
953
893
  #applySchemaUpdate() {
@@ -958,17 +898,32 @@ export class DocumentController {
958
898
  ? this.options.runtime.manifest?.globals
959
899
  : this.options.runtime.manifest?.collections
960
900
  )?.find((item) => item.id === previous.id);
901
+ this.#cancelLoad();
902
+ this.#routeGeneration += 1;
903
+ this.#cancelRouteRequests();
904
+ this.#saveRequest?.abort();
905
+ this.publicationOperation = false;
906
+ this.duplicateOperation = false;
907
+ this.lock.pause();
908
+ const result = this.form.reconcile(
909
+ previous.fields,
910
+ next?.fields ?? previous.fields,
911
+ this.creating
912
+ );
961
913
  if (next === undefined) {
914
+ this.lock.release();
962
915
  this.collectionAvailable = false;
916
+ this.form.writeBlocked = true;
917
+ this.form.setAccess(undefined, this.creating ? "create" : "update");
963
918
  this.options.notifications.error({
964
919
  title: this.options.runtime.i18n.t("documents:collectionRemoved"),
965
920
  });
966
921
  return;
967
922
  }
968
923
 
969
- const result = this.form.reconcile(previous.fields, next.fields, this.creating);
970
924
  this.collection = next;
971
925
  this.collectionAvailable = true;
926
+ if (!next.capabilities.locking || previous.slug !== next.slug) this.lock.release();
972
927
  if (result.detached.length > 0) {
973
928
  this.options.notifications.warning({
974
929
  title: this.options.runtime.i18n.t("documents:schemaUpdatedWithDraft"),
@@ -978,6 +933,15 @@ export class DocumentController {
978
933
  });
979
934
  }
980
935
  if (previous.slug !== next.slug) {
936
+ // The resulting URL is the same editor under its renamed resource, so its
937
+ // next route synchronization must not reload and replace the retained draft.
938
+ const documentID = this.globalResource ? next.slug : this.documentID;
939
+ this.#activeRouteKey = `${this.globalResource ? "global" : "collection"}:${next.slug}:${documentID ?? "new"}:${this.contentLocale ?? "default"}:${this.options.editable ? "edit" : "read"}`;
940
+ this.form.setResource({
941
+ collection: next.slug,
942
+ id: documentID,
943
+ global: this.globalResource,
944
+ });
981
945
  this.options.navigate(
982
946
  withContentLocale(
983
947
  this.globalResource
@@ -990,13 +954,21 @@ export class DocumentController {
990
954
  { replace: true }
991
955
  );
992
956
  }
957
+ // Field access is keyed by path, so the old envelope is invalid after reconciliation.
958
+ this.form.setAccess(undefined, this.creating ? "create" : "update");
959
+ this.form.writeBlocked = true;
960
+ this.#load(
961
+ next.slug,
962
+ this.globalResource ? next.slug : this.documentID,
963
+ this.currentDocument !== undefined
964
+ );
993
965
  }
994
966
 
995
967
  #restoreDraft() {
996
968
  const collection = this.collection;
997
- if (collection === undefined) return;
969
+ if (collection === undefined) return false;
998
970
  const checkpoint = takeFormDraft(collection.id, this.documentID);
999
- if (checkpoint === undefined) return;
971
+ if (checkpoint === undefined) return false;
1000
972
  const result = this.form.recover(
1001
973
  { values: checkpoint.values, original: checkpoint.original },
1002
974
  checkpoint.collection.fields,
@@ -1017,53 +989,81 @@ export class DocumentController {
1017
989
  if (result.restoredFields === 0 || result.detached.length > 0)
1018
990
  this.options.notifications.warning({ title, message });
1019
991
  else this.options.notifications.success({ title });
992
+ return result.restoredFields > 0;
1020
993
  }
1021
994
 
1022
- async #load(slug: string, documentID: string, signal: AbortSignal) {
995
+ #cancelLoad() {
996
+ this.#loadRequest?.abort();
997
+ this.#loadRequest = undefined;
998
+ this.loading = false;
999
+ }
1000
+
1001
+ async #load(
1002
+ slug: string,
1003
+ documentID?: string,
1004
+ preserveDocument = false,
1005
+ prepared?: AdminDocumentDataV1 | AdminCreateDataV1
1006
+ ) {
1007
+ this.#loadRequest?.abort();
1008
+ this.#loadRequest = undefined;
1009
+ if (prepared !== undefined && this.#adoptPreparedLoad(prepared, slug, documentID)) return;
1010
+ const request = new AbortController();
1011
+ this.#loadRequest = request;
1012
+ const { signal } = request;
1013
+ const global = this.globalResource;
1014
+ const locale = this.contentLocale;
1015
+ // A refresh may replace the draft it started with, never edits made while it was pending.
1016
+ const revision = this.form.revision;
1017
+ const uploadRevision = this.upload.revision;
1018
+ const changeGeneration = this.#formChangeGeneration;
1023
1019
  this.loading = true;
1024
1020
  this.error = undefined;
1025
1021
  try {
1026
1022
  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
- })
1023
+ documentID === undefined || preserveDocument
1024
+ ? undefined
1025
+ : global
1026
+ ? this.options.runtime.client.global(slug, { signal, locale })
1027
+ : this.options.runtime.client.find(slug, documentID, { signal, locale }),
1028
+ global
1029
+ ? this.options.runtime.client.globalAccess(slug, { signal, locale })
1041
1030
  : this.options.runtime.client.collectionAccess(slug, {
1042
1031
  id: documentID,
1032
+ ...(documentID === undefined ? { data: $state.snapshot(this.form.values) } : {}),
1043
1033
  signal,
1044
- locale: this.contentLocale,
1034
+ locale,
1045
1035
  }),
1046
1036
  ]);
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 = [];
1037
+ if (signal.aborted) return;
1038
+ const formUnchanged =
1039
+ this.form.revision === revision &&
1040
+ this.upload.revision === uploadRevision &&
1041
+ this.#formChangeGeneration === changeGeneration;
1042
+ this.form.setAccess(access, documentID === undefined ? "create" : "update");
1043
+ if (document !== undefined && formUnchanged) {
1044
+ const uncertain = this.saveOutcomeUncertain;
1045
+ if (uncertain) clearFormDraft(this.collection?.id ?? slug, documentID);
1046
+ this.#applyDocument(document, !uncertain);
1061
1047
  }
1062
- if (!this.globalResource && this.collection?.capabilities.locking === true) {
1063
- await this.#acquireLock(slug, documentID, signal);
1048
+ if (
1049
+ documentID !== undefined &&
1050
+ !global &&
1051
+ this.options.editable &&
1052
+ this.collection?.capabilities.locking === true
1053
+ ) {
1054
+ // The form is complete before the lock request starts. Writes remain
1055
+ // disabled until the server confirms ownership.
1056
+ this.form.writeBlocked = true;
1057
+ this.loading = false;
1058
+ await tick();
1059
+ if (signal.aborted || this.#loadRequest !== request) return;
1060
+ await this.lock.acquire(
1061
+ slug,
1062
+ documentID,
1063
+ this.collection.documentLockSettings?.durationSeconds ?? 120
1064
+ );
1064
1065
  } else {
1065
- this.documentLock = undefined;
1066
- this.form.writeBlocked = false;
1066
+ this.lock.release();
1067
1067
  }
1068
1068
  } catch (cause) {
1069
1069
  if (signal.aborted) return;
@@ -1072,187 +1072,101 @@ export class DocumentController {
1072
1072
  ? cause.message
1073
1073
  : this.options.runtime.i18n.t("documents:loadFailed");
1074
1074
  } 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.
1075
+ if (this.#loadRequest === request) {
1076
+ this.#loadRequest = undefined;
1077
+ this.loading = false;
1109
1078
  }
1110
- }, interval * 1_000);
1079
+ }
1111
1080
  }
1112
1081
 
1113
- #releaseLock() {
1114
- if (this.#lockRefreshTimer !== undefined) {
1115
- window.clearInterval(this.#lockRefreshTimer);
1116
- this.#lockRefreshTimer = undefined;
1082
+ #adoptPreparedLoad(
1083
+ prepared: AdminDocumentDataV1 | AdminCreateDataV1,
1084
+ slug: string,
1085
+ documentID?: string
1086
+ ) {
1087
+ if (
1088
+ "values" in prepared &&
1089
+ !samePreparedCreateValues(prepared.values, $state.snapshot(this.form.values))
1090
+ )
1091
+ return false;
1092
+ const document = "document" in prepared ? prepared.document : undefined;
1093
+ const access = prepared.access;
1094
+ this.loading = false;
1095
+ this.error = document?.error?.message ?? access.error?.message;
1096
+ if (this.error !== undefined) {
1097
+ this.form.setAccess(undefined, documentID === undefined ? "create" : "update");
1098
+ return true;
1117
1099
  }
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(() => {});
1100
+ this.form.setAccess(access.value, documentID === undefined ? "create" : "update");
1101
+ if (document?.value !== undefined) this.#applyDocument(document.value, true);
1102
+ if (
1103
+ documentID !== undefined &&
1104
+ !this.globalResource &&
1105
+ this.options.editable &&
1106
+ this.collection?.capabilities.locking === true
1107
+ ) {
1108
+ this.form.writeBlocked = true;
1109
+ this.#acquirePreparedLock(this.#routeGeneration, slug, documentID);
1110
+ } else {
1111
+ this.lock.release();
1127
1112
  }
1113
+ return true;
1128
1114
  }
1129
1115
 
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
- }
1116
+ async #acquirePreparedLock(generation: number, slug: string, documentID: string) {
1117
+ await tick();
1118
+ if (
1119
+ generation !== this.#routeGeneration ||
1120
+ this.documentID !== documentID ||
1121
+ !this.options.editable
1122
+ )
1123
+ return;
1124
+ await this.lock.acquire(
1125
+ slug,
1126
+ documentID,
1127
+ this.collection?.documentLockSettings?.durationSeconds ?? 120
1128
+ );
1150
1129
  }
1151
1130
 
1152
1131
  async #refreshAccess(documentID: string) {
1153
- const routeKey = this.#activeRouteKey;
1132
+ const generation = this.#routeGeneration;
1154
1133
  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,
1134
+ const global = this.globalResource;
1135
+ const locale = this.contentLocale;
1136
+ try {
1137
+ const access = global
1138
+ ? await this.options.runtime.client.globalAccess(collectionSlug, { locale })
1139
+ : await this.options.runtime.client.collectionAccess(collectionSlug, {
1140
+ id: documentID,
1141
+ locale,
1176
1142
  });
1177
- if (this.#activeRouteKey === routeKey) this.versions = versions;
1178
- } else {
1179
- if (this.#activeRouteKey === routeKey) this.versions = [];
1143
+ if (generation !== this.#routeGeneration) return;
1144
+ this.form.setAccess(access, "update");
1145
+ this.error = undefined;
1146
+ } catch (cause) {
1147
+ if (generation !== this.#routeGeneration) return;
1148
+ this.form.setAccess(undefined, "update");
1149
+ this.error =
1150
+ cause instanceof Error
1151
+ ? cause.message
1152
+ : this.options.runtime.i18n.t("documents:accessEvaluationFailed");
1180
1153
  }
1181
1154
  }
1182
1155
 
1183
1156
  #applyDocument(document: AdminDocument, recoverDraft = false) {
1157
+ this.saveOutcomeUncertain = false;
1184
1158
  this.currentDocument = document;
1159
+ this.upload.reset(document);
1185
1160
  this.form.reset(
1186
1161
  documentFormValues(this.collection?.fields ?? [], document),
1187
1162
  this.collection?.fields ?? []
1188
1163
  );
1189
1164
  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
1165
  if (recoverDraft) this.#restoreDraft();
1193
1166
  }
1194
1167
 
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
1168
  #documentString(name: string) {
1207
1169
  const value = this.currentDocument?.[name];
1208
1170
  return typeof value === "string" && value.length > 0 ? value : undefined;
1209
1171
  }
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
1172
  }