@michaelthielemann/kestrel 4.1.2 → 5.0.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 (340) hide show
  1. package/README.md +26 -201
  2. package/dist/boot.d.ts +45 -0
  3. package/dist/boot.js +274 -0
  4. package/dist/cast.d.ts +3 -0
  5. package/dist/cast.js +6 -0
  6. package/dist/catalogue.d.ts +11 -0
  7. package/dist/catalogue.js +1 -0
  8. package/dist/cli.d.ts +2 -0
  9. package/dist/cli.js +55 -0
  10. package/dist/context.d.ts +62 -0
  11. package/dist/context.js +61 -0
  12. package/dist/dataflow.d.ts +3 -0
  13. package/dist/dataflow.js +65 -0
  14. package/dist/defineConfig.d.ts +157 -0
  15. package/dist/defineConfig.js +41 -0
  16. package/dist/defineContract.d.ts +12 -0
  17. package/dist/defineContract.js +16 -0
  18. package/dist/defineModule.d.ts +75 -0
  19. package/dist/defineModule.js +9 -0
  20. package/dist/definePipeline.d.ts +7 -0
  21. package/dist/definePipeline.js +11 -0
  22. package/dist/errors.d.ts +26 -0
  23. package/dist/errors.js +48 -0
  24. package/dist/index.d.ts +19 -0
  25. package/dist/index.js +14 -0
  26. package/dist/load.d.ts +7 -0
  27. package/dist/load.js +79 -0
  28. package/dist/logger.d.ts +18 -0
  29. package/dist/logger.js +36 -0
  30. package/dist/registry.d.ts +14 -0
  31. package/dist/registry.js +87 -0
  32. package/dist/result.d.ts +19 -0
  33. package/dist/result.js +19 -0
  34. package/dist/runner.d.ts +29 -0
  35. package/dist/runner.js +133 -0
  36. package/dist/schema.d.ts +7 -0
  37. package/dist/schema.js +142 -0
  38. package/dist/sort.d.ts +2 -0
  39. package/dist/sort.js +50 -0
  40. package/dist/testing/runPipeline.d.ts +9 -0
  41. package/dist/testing/runPipeline.js +22 -0
  42. package/dist/triggers/cron.d.ts +10 -0
  43. package/dist/triggers/cron.js +44 -0
  44. package/dist/triggers/cronMatch.d.ts +10 -0
  45. package/dist/triggers/cronMatch.js +68 -0
  46. package/dist/triggers/http.d.ts +58 -0
  47. package/dist/triggers/http.js +292 -0
  48. package/package.json +25 -151
  49. package/NOTICE +0 -12
  50. package/kestrel.config.ts +0 -28
  51. package/layers/access/nuxt.config.ts +0 -5
  52. package/layers/access/server/middleware/00.ip-allowlist.ts +0 -52
  53. package/layers/access/server/middleware/access-guard.ts +0 -32
  54. package/layers/access/server/types.d.ts +0 -7
  55. package/layers/admin/app/components/AdminAccount.vue +0 -212
  56. package/layers/admin/app/components/AdminNav.vue +0 -44
  57. package/layers/admin/app/components/BlockFields.vue +0 -67
  58. package/layers/admin/app/components/BlockPreview.vue +0 -413
  59. package/layers/admin/app/components/BlockTree.vue +0 -433
  60. package/layers/admin/app/components/BlocksBody.vue +0 -163
  61. package/layers/admin/app/components/CollectionDeleteDialog.vue +0 -38
  62. package/layers/admin/app/components/CollectionEditor.vue +0 -324
  63. package/layers/admin/app/components/CollectionList.vue +0 -429
  64. package/layers/admin/app/components/CollectionListBulkBar.vue +0 -62
  65. package/layers/admin/app/components/CollectionListColumnsPanel.vue +0 -37
  66. package/layers/admin/app/components/CollectionListFilterPanel.vue +0 -145
  67. package/layers/admin/app/components/CollectionListPager.vue +0 -70
  68. package/layers/admin/app/components/CollectionListTable.vue +0 -347
  69. package/layers/admin/app/components/EditorStatus.vue +0 -211
  70. package/layers/admin/app/components/EditorUnsupported.vue +0 -28
  71. package/layers/admin/app/components/FieldsBody.vue +0 -23
  72. package/layers/admin/app/components/LocaleBar.vue +0 -107
  73. package/layers/admin/app/components/PageFields.vue +0 -129
  74. package/layers/admin/app/components/PageFieldsPane.vue +0 -39
  75. package/layers/admin/app/components/SeoFields.vue +0 -210
  76. package/layers/admin/app/components/SingletonEditor.vue +0 -93
  77. package/layers/admin/app/composables/useAuth.ts +0 -68
  78. package/layers/admin/app/composables/useBlockTree.ts +0 -95
  79. package/layers/admin/app/composables/useBlocks.ts +0 -7
  80. package/layers/admin/app/composables/useCachedResource.ts +0 -35
  81. package/layers/admin/app/composables/useCollectionOps.ts +0 -102
  82. package/layers/admin/app/composables/useCollections.ts +0 -6
  83. package/layers/admin/app/composables/useContentLocales.ts +0 -23
  84. package/layers/admin/app/composables/useEditForm.ts +0 -402
  85. package/layers/admin/app/composables/useLinkResolver.ts +0 -40
  86. package/layers/admin/app/composables/useListBatchActions.ts +0 -78
  87. package/layers/admin/app/composables/useListColumns.ts +0 -53
  88. package/layers/admin/app/composables/useListFilterDraft.ts +0 -129
  89. package/layers/admin/app/composables/useListRows.ts +0 -75
  90. package/layers/admin/app/composables/useListSelection.ts +0 -28
  91. package/layers/admin/app/composables/useListUrlState.ts +0 -53
  92. package/layers/admin/app/composables/usePendingPublishPoll.ts +0 -15
  93. package/layers/admin/app/composables/usePreviewViewport.ts +0 -30
  94. package/layers/admin/app/composables/usePublishStatus.ts +0 -107
  95. package/layers/admin/app/composables/useRailCollapsed.ts +0 -14
  96. package/layers/admin/app/composables/useTheme.ts +0 -36
  97. package/layers/admin/app/composables/useToolbarPanel.ts +0 -38
  98. package/layers/admin/app/composables/useUnsavedGuard.ts +0 -22
  99. package/layers/admin/app/layouts/admin.vue +0 -266
  100. package/layers/admin/app/middleware/admin-auth.ts +0 -4
  101. package/layers/admin/app/pages/admin/[collection]/[id].vue +0 -203
  102. package/layers/admin/app/pages/admin/[collection]/index.vue +0 -62
  103. package/layers/admin/app/pages/admin/index.vue +0 -118
  104. package/layers/admin/app/pages/admin/login.vue +0 -68
  105. package/layers/admin/app/pages/admin/media.vue +0 -28
  106. package/layers/admin/app/pages/admin/references.vue +0 -146
  107. package/layers/admin/app/plugins/admin-style-guard.client.ts +0 -76
  108. package/layers/admin/app/plugins/reauth.client.ts +0 -20
  109. package/layers/admin/app/utils/admin-style-guard.ts +0 -20
  110. package/layers/admin/app/utils/block-tree.ts +0 -214
  111. package/layers/admin/app/utils/collection-ops.ts +0 -69
  112. package/layers/admin/app/utils/dead-refs.ts +0 -30
  113. package/layers/admin/app/utils/edit-form.ts +0 -267
  114. package/layers/admin/app/utils/editor-expose.ts +0 -36
  115. package/layers/admin/app/utils/editor-form-context.ts +0 -82
  116. package/layers/admin/app/utils/editor-registry.ts +0 -20
  117. package/layers/admin/app/utils/field-keys.ts +0 -11
  118. package/layers/admin/app/utils/list-cell.ts +0 -46
  119. package/layers/admin/app/utils/list-columns.ts +0 -142
  120. package/layers/admin/app/utils/list-query.ts +0 -96
  121. package/layers/admin/app/utils/nav-active.ts +0 -9
  122. package/layers/admin/app/utils/populate-blocks.ts +0 -288
  123. package/layers/admin/app/utils/preview-channel.ts +0 -62
  124. package/layers/admin/app/utils/preview-viewport.ts +0 -80
  125. package/layers/admin/app/utils/reauth.ts +0 -67
  126. package/layers/admin/app/utils/record-title.ts +0 -15
  127. package/layers/admin/app/utils/register-builtin-editors.ts +0 -9
  128. package/layers/admin/app/utils/safe-redirect.ts +0 -12
  129. package/layers/admin/nuxt.config.ts +0 -20
  130. package/layers/auth/nuxt.config.ts +0 -1
  131. package/layers/auth/server/plugins/01.register-auth-pipelines.ts +0 -7
  132. package/layers/collections/nuxt.config.ts +0 -1
  133. package/layers/collections/server/plugins/01.register.ts +0 -29
  134. package/layers/collections/server/plugins/02.register-relation-populate.ts +0 -18
  135. package/layers/core/app/composables/layouts.ts +0 -5
  136. package/layers/core/app/utils/field-factories.ts +0 -129
  137. package/layers/core/app/utils/layouts.ts +0 -38
  138. package/layers/core/modules/auto-discovery/extract-block.ts +0 -179
  139. package/layers/core/modules/auto-discovery/index.ts +0 -112
  140. package/layers/core/modules/auto-discovery/package-registry.ts +0 -19
  141. package/layers/core/modules/auto-discovery/scan.ts +0 -170
  142. package/layers/core/modules/auto-discovery/virtual.d.ts +0 -20
  143. package/layers/core/modules/component-namespace/index.ts +0 -44
  144. package/layers/core/modules/component-namespace/shared.ts +0 -24
  145. package/layers/core/modules/kestrel/app-shell.ts +0 -55
  146. package/layers/core/modules/kestrel/auto-imports.ts +0 -44
  147. package/layers/core/modules/kestrel/index.ts +0 -144
  148. package/layers/core/modules/plugin-order/index.ts +0 -69
  149. package/layers/core/modules/plugin-order/plugin-order.ts +0 -273
  150. package/layers/core/nuxt.config.ts +0 -10
  151. package/layers/core/server/api/[...path].ts +0 -98
  152. package/layers/core/server/plugins/00.config.ts +0 -12
  153. package/layers/core/server/plugins/00.migrate.ts +0 -12
  154. package/layers/core/server/plugins/01.register-introspection-pipeline.ts +0 -5
  155. package/layers/core/server/plugins/01.register-openapi-pipeline.ts +0 -5
  156. package/layers/core/server/plugins/01.register-tooling-pipelines.ts +0 -5
  157. package/layers/core/server/plugins/02.schema-sync.ts +0 -58
  158. package/layers/core/server/plugins/04.outbox-worker.ts +0 -31
  159. package/layers/core/server/plugins/05.reindex-refs.ts +0 -5
  160. package/layers/core/server/routes/__kestrel/dashboard.get.ts +0 -29
  161. package/layers/core/server/tasks/db/migrate-module.ts +0 -83
  162. package/layers/core/server/tasks/db/migrate-revisions.ts +0 -20
  163. package/layers/core/server/tasks/db/migrate.ts +0 -50
  164. package/layers/core/server/utils/server-config.ts +0 -81
  165. package/layers/fields/nuxt.config.ts +0 -1
  166. package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +0 -7
  167. package/layers/fields/server/plugins/01.register-field-populate.ts +0 -11
  168. package/layers/media/app/components/KestrelImg.vue +0 -94
  169. package/layers/media/app/components/MediaConflictDialog.vue +0 -35
  170. package/layers/media/app/components/MediaDeleteDialog.vue +0 -52
  171. package/layers/media/app/components/MediaGrid.vue +0 -113
  172. package/layers/media/app/components/MediaImage.vue +0 -70
  173. package/layers/media/app/components/MediaLibrary.vue +0 -456
  174. package/layers/media/app/components/MediaNewFolderDialog.vue +0 -32
  175. package/layers/media/app/components/MediaPathBar.vue +0 -163
  176. package/layers/media/app/components/MediaPicker.vue +0 -19
  177. package/layers/media/app/components/MediaRenameDialog.vue +0 -32
  178. package/layers/media/app/components/MediaTable.vue +0 -75
  179. package/layers/media/app/components/MediaThumb.vue +0 -25
  180. package/layers/media/app/components/MediaToolbar.vue +0 -90
  181. package/layers/media/app/components/MediaUploadDialog.vue +0 -87
  182. package/layers/media/app/components/MediaViewer.vue +0 -137
  183. package/layers/media/app/components/field/Media.vue +0 -263
  184. package/layers/media/app/composables/useBlurUp.ts +0 -55
  185. package/layers/media/app/composables/useMediaClipboard.ts +0 -14
  186. package/layers/media/app/composables/useMediaContextMenu.ts +0 -56
  187. package/layers/media/app/composables/useMediaDnd.ts +0 -56
  188. package/layers/media/app/composables/useMediaLibrary.ts +0 -152
  189. package/layers/media/app/composables/useMediaOps.ts +0 -50
  190. package/layers/media/app/composables/useMediaRelocate.ts +0 -39
  191. package/layers/media/app/composables/useMediaResolver.ts +0 -50
  192. package/layers/media/app/composables/useMediaUpload.ts +0 -93
  193. package/layers/media/app/composables/useMediaVariant.ts +0 -34
  194. package/layers/media/app/plugins/field-media.client.ts +0 -6
  195. package/layers/media/app/utils/ai-disclosure.ts +0 -19
  196. package/layers/media/app/utils/dnd.ts +0 -69
  197. package/layers/media/app/utils/library.ts +0 -81
  198. package/layers/media/app/utils/ops.ts +0 -60
  199. package/layers/media/app/utils/picture-model.ts +0 -125
  200. package/layers/media/app/utils/thumbhash.ts +0 -19
  201. package/layers/media/app/utils/variant-name.ts +0 -20
  202. package/layers/media/nuxt.config.ts +0 -7
  203. package/layers/media/server/middleware/ondemand-variants.ts +0 -38
  204. package/layers/media/server/plugins/01.register-media-pipelines.ts +0 -7
  205. package/layers/media/server/plugins/02.register-media.ts +0 -22
  206. package/layers/media/server/plugins/04.variant-capture.ts +0 -27
  207. package/layers/media/server/plugins/05.media-cleanup.ts +0 -5
  208. package/layers/media/server/tasks/media/backfill.ts +0 -17
  209. package/layers/public/app/app.vue +0 -5
  210. package/layers/public/app/components/BlockRenderer.vue +0 -56
  211. package/layers/public/app/components/KestrelLink.vue +0 -26
  212. package/layers/public/app/components/KestrelPreviewBridge.vue +0 -133
  213. package/layers/public/app/composables/public-page.ts +0 -24
  214. package/layers/public/app/error.vue +0 -12
  215. package/layers/public/app/layouts/default.vue +0 -5
  216. package/layers/public/app/pages/[...slug].vue +0 -239
  217. package/layers/public/app/pages/__kestrel/preview.vue +0 -46
  218. package/layers/public/app/utils/block-edit-context.ts +0 -16
  219. package/layers/public/app/utils/json-ld.ts +0 -139
  220. package/layers/public/app/utils/link-href.ts +0 -46
  221. package/layers/public/app/utils/page-head.ts +0 -84
  222. package/layers/public/app/utils/page-layout.ts +0 -18
  223. package/layers/public/app/utils/preview-protocol.ts +0 -117
  224. package/layers/public/app/utils/public-route.ts +0 -46
  225. package/layers/public/app/utils/site-head.ts +0 -43
  226. package/layers/public/modules/deploy-output/index.ts +0 -80
  227. package/layers/public/modules/prerender-routes/discover.ts +0 -37
  228. package/layers/public/modules/prerender-routes/index.ts +0 -88
  229. package/layers/public/modules/prerender-routes/routes.ts +0 -16
  230. package/layers/public/modules/prune-media/index.ts +0 -48
  231. package/layers/public/modules/prune-media/prune-media.ts +0 -81
  232. package/layers/public/nuxt.config.ts +0 -14
  233. package/layers/public/server/delivery-live/serve.ts +0 -111
  234. package/layers/public/server/middleware/delivery-live-catchall.ts +0 -7
  235. package/layers/public/server/pipelines/index.ts +0 -23
  236. package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +0 -14
  237. package/layers/public/server/plugins/01.register-public-pipelines.ts +0 -7
  238. package/layers/public/server/plugins/02.register-links.ts +0 -16
  239. package/layers/public/server/plugins/03.redirects.ts +0 -55
  240. package/layers/public/server/plugins/05.plan-publish.ts +0 -5
  241. package/layers/public/server/plugins/zz.publish.ts +0 -127
  242. package/layers/public/server/routes/llms-full.txt.get.ts +0 -99
  243. package/layers/public/server/routes/llms.txt.get.ts +0 -66
  244. package/layers/public/server/routes/redirects.json.get.ts +0 -58
  245. package/layers/public/server/routes/robots.txt.get.ts +0 -11
  246. package/layers/public/server/routes/sitemap.xml.get.ts +0 -79
  247. package/layers/public/server/tasks/publish/run.ts +0 -32
  248. package/layers/public/server/utils/publish/render-live.ts +0 -26
  249. package/layers/ui/app/assets/scss/_base.scss +0 -16
  250. package/layers/ui/app/assets/scss/_datepicker.scss +0 -82
  251. package/layers/ui/app/assets/scss/_mixins.scss +0 -50
  252. package/layers/ui/app/assets/scss/_reset.scss +0 -65
  253. package/layers/ui/app/assets/scss/_tokens.scss +0 -183
  254. package/layers/ui/app/assets/scss/main.scss +0 -4
  255. package/layers/ui/app/components/field/Boolean.vue +0 -30
  256. package/layers/ui/app/components/field/Choice.vue +0 -73
  257. package/layers/ui/app/components/field/Datetime.vue +0 -98
  258. package/layers/ui/app/components/field/DeadRefNote.vue +0 -28
  259. package/layers/ui/app/components/field/Json.vue +0 -62
  260. package/layers/ui/app/components/field/Layout.vue +0 -138
  261. package/layers/ui/app/components/field/Link.vue +0 -112
  262. package/layers/ui/app/components/field/LinkInternalPicker.vue +0 -95
  263. package/layers/ui/app/components/field/Number.vue +0 -30
  264. package/layers/ui/app/components/field/Relation.vue +0 -49
  265. package/layers/ui/app/components/field/Renderer.vue +0 -30
  266. package/layers/ui/app/components/field/Repeater.vue +0 -460
  267. package/layers/ui/app/components/field/Richtext.vue +0 -26
  268. package/layers/ui/app/components/field/Slug.vue +0 -65
  269. package/layers/ui/app/components/field/Text.vue +0 -35
  270. package/layers/ui/app/components/field/Unsupported.vue +0 -34
  271. package/layers/ui/app/components/ui/Alert.vue +0 -50
  272. package/layers/ui/app/components/ui/Brand.vue +0 -22
  273. package/layers/ui/app/components/ui/Button.vue +0 -116
  274. package/layers/ui/app/components/ui/ButtonGroup.vue +0 -96
  275. package/layers/ui/app/components/ui/Checkbox.vue +0 -53
  276. package/layers/ui/app/components/ui/CheckboxGroup.vue +0 -51
  277. package/layers/ui/app/components/ui/Combobox.vue +0 -345
  278. package/layers/ui/app/components/ui/DatePicker.vue +0 -92
  279. package/layers/ui/app/components/ui/DateRangePicker.vue +0 -100
  280. package/layers/ui/app/components/ui/Dialog.vue +0 -107
  281. package/layers/ui/app/components/ui/EmptyState.vue +0 -46
  282. package/layers/ui/app/components/ui/Field.vue +0 -58
  283. package/layers/ui/app/components/ui/Fieldset.vue +0 -65
  284. package/layers/ui/app/components/ui/Icon.vue +0 -44
  285. package/layers/ui/app/components/ui/Menu.vue +0 -54
  286. package/layers/ui/app/components/ui/NumberInput.vue +0 -175
  287. package/layers/ui/app/components/ui/Richtext.vue +0 -141
  288. package/layers/ui/app/components/ui/RichtextToolbar.vue +0 -251
  289. package/layers/ui/app/components/ui/Select.vue +0 -52
  290. package/layers/ui/app/components/ui/TextInput.vue +0 -111
  291. package/layers/ui/app/components/ui/Textarea.vue +0 -39
  292. package/layers/ui/app/components/ui/TimeInput.vue +0 -18
  293. package/layers/ui/app/components/ui/Toasts.vue +0 -103
  294. package/layers/ui/app/components/ui/Tooltip.vue +0 -45
  295. package/layers/ui/app/composables/useAdminLang.ts +0 -8
  296. package/layers/ui/app/composables/useDragReorder.ts +0 -41
  297. package/layers/ui/app/composables/useEchoGuard.ts +0 -28
  298. package/layers/ui/app/composables/useLinkField.ts +0 -70
  299. package/layers/ui/app/composables/useRecordOptions.ts +0 -84
  300. package/layers/ui/app/composables/useRepeater.ts +0 -99
  301. package/layers/ui/app/composables/useT.ts +0 -32
  302. package/layers/ui/app/composables/useToast.ts +0 -46
  303. package/layers/ui/app/i18n/de.ts +0 -432
  304. package/layers/ui/app/i18n/en.ts +0 -434
  305. package/layers/ui/app/utils/boolean-field.ts +0 -10
  306. package/layers/ui/app/utils/field-component.ts +0 -22
  307. package/layers/ui/app/utils/field-empty.ts +0 -21
  308. package/layers/ui/app/utils/field-registry.ts +0 -36
  309. package/layers/ui/app/utils/humanize.ts +0 -14
  310. package/layers/ui/app/utils/icons.ts +0 -143
  311. package/layers/ui/app/utils/iso-date.ts +0 -18
  312. package/layers/ui/app/utils/localized.ts +0 -13
  313. package/layers/ui/app/utils/record-context.ts +0 -15
  314. package/layers/ui/app/utils/reorder.ts +0 -8
  315. package/layers/ui/app/utils/richtext-preserve-class.ts +0 -31
  316. package/layers/ui/app/utils/useFieldA11y.ts +0 -40
  317. package/layers/ui/nuxt.config.ts +0 -9
  318. package/nuxt.config.ts +0 -108
  319. package/scripts/consumer-template-ci.mjs +0 -160
  320. package/scripts/copy-create-payload.mjs +0 -49
  321. package/scripts/coverage-all.mjs +0 -163
  322. package/scripts/dashboard.mjs +0 -89
  323. package/scripts/hash-password.mjs +0 -29
  324. package/scripts/kestrel.mjs +0 -240
  325. package/scripts/lib/cli.mjs +0 -129
  326. package/scripts/lib/coverage-model.mjs +0 -51
  327. package/scripts/lib/password.mjs +0 -19
  328. package/scripts/lib/scaffold.mjs +0 -205
  329. package/scripts/port-graph.mjs +0 -350
  330. package/scripts/typecheck.mjs +0 -62
  331. package/templates/starter/README.md +0 -65
  332. package/templates/starter/_env.example +0 -17
  333. package/templates/starter/_gitignore +0 -26
  334. package/templates/starter/_package.json +0 -22
  335. package/templates/starter/app/app.vue +0 -7
  336. package/templates/starter/app/blocks/Prose.vue +0 -13
  337. package/templates/starter/app/layouts/default.vue +0 -6
  338. package/templates/starter/nuxt.config.ts +0 -23
  339. package/templates/starter/pnpm-workspace.yaml +0 -8
  340. package/templates/starter/tsconfig.json +0 -3
@@ -1,402 +0,0 @@
1
- import type { LayoutNode, SerializedField } from '@michaelthielemann/kestrel-core'
2
- import { isFieldVisible } from '@michaelthielemann/kestrel-core/client'
3
- import { validateField } from '@michaelthielemann/kestrel-fields/client'
4
- import type { BlockErrorMap } from '../utils/edit-form'
5
- import type { DeadRef } from '../utils/dead-refs'
6
- import { asFieldDef, initialValues, mapServerErrors, parseBlockErrors, reconcileBlockErrors, reconcileDeadRefs, readFetchError } from '../utils/edit-form'
7
-
8
- export interface UseEditFormOptions {
9
- collection: string
10
- id: string
11
- /** Requested content locale (from the `?locale` route query). Defaults to the primary locale. */
12
- locale?: string
13
- /** translationGroup to link a NEW multi translation to its siblings (from the `?group` query). */
14
- group?: string
15
- }
16
-
17
- export type SubmitResult = { ok: true; record: unknown } | { ok: false }
18
-
19
- function snapshot(values: Record<string, unknown>): Record<string, unknown> {
20
- return JSON.parse(JSON.stringify(values))
21
- }
22
-
23
- export function useEditForm(opts: UseEditFormOptions) {
24
- const { collection, id } = opts
25
- const { t } = useT()
26
- const { primary } = useContentLocales()
27
- // The locale the editor *requests* (single GET/PUT + a new multi translation). For an existing multi
28
- // row the active locale comes from the row itself (set in init); it can't be switched via a query.
29
- const requestedLocale = opts.locale?.trim() || primary
30
- const activeLocale = ref(requestedLocale)
31
- const translations = ref<Record<string, number | null>>({})
32
- // Stale references this record currently holds (derived on read) — drives the editor's dead-ref warnings.
33
- const deadRefs = ref<DeadRef[]>([])
34
- // The translation group the LocaleBar links new siblings to: the requested group for a new
35
- // translation, or the loaded row's own group for an existing multi record.
36
- const translationGroup = ref<string | undefined>(opts.group?.trim() || undefined)
37
-
38
- const fields = ref<Record<string, SerializedField>>({})
39
- // The collection's normalized admin editor layout (resolved server-side), or undefined → one field per row.
40
- const fieldLayout = ref<LayoutNode[] | undefined>(undefined)
41
- const mode = ref<'multi' | 'single'>('multi')
42
- const translatable = ref(false)
43
- const blocksEnabled = ref(false)
44
- const blocksAllowed = ref<string[] | undefined>(undefined)
45
- // pageLike collections carry a routable `path` (the slug) as a system column, edited in the page editor.
46
- const pageLike = ref(false)
47
- // seo-enabled collections carry a `seo` JSON system column ({ title, description, noindex }).
48
- const hasSeo = ref(false)
49
- // status-enabled collections carry a `status` system column ('draft' | 'published') — the publish gate.
50
- const hasStatus = ref(false)
51
- // Which editor body renders (the `editor` type on the serialized collection). The server resolves it,
52
- // but fall back defensively (blocks → 'blocks', else 'fields') so an older/partial schema still works.
53
- const editorType = ref('fields')
54
-
55
- // Set when the loaded row is the quarantine shape (`{ id, $quarantined: true }`) — a stored row
56
- // that no longer matches its collection's select schema. The form locks read-only; nothing here can
57
- // widen what a corrupted row exposes.
58
- const quarantined = ref(false)
59
-
60
- const values = reactive<Record<string, unknown>>({})
61
- const errors = reactive<Record<string, string>>({})
62
- const formError = ref('')
63
- const blockErrors = ref<BlockErrorMap>({})
64
- const saving = ref(false)
65
- const baseline = ref<Record<string, unknown>>({})
66
- const baseUpdatedAt = ref<number | undefined>(undefined)
67
-
68
- const dirty = computed(() => !valuesEqual(values, baseline.value))
69
- // The LAST SAVED publish status (from the baseline snapshot, not the live edited value). The editor's
70
- // right "live" lamp keys its draft/published decision off this — an UNSAVED dropdown switch must not flip
71
- // the generated-state indicator (nothing is generated until the change is saved). '' when statusless.
72
- const savedStatus = computed(() => (hasStatus.value ? ((baseline.value.status as string | undefined) ?? '') : ''))
73
- // The LAST SAVED page path (baseline, not the live edited slug). The preview iframe + the header's
74
- // open-in-new-tab must target a URL that actually EXISTS — an unsaved slug edit would 404 until saved;
75
- // on save the baseline moves and both follow to the new URL. '' when non-pageLike or never saved.
76
- const savedPath = computed(() => (pageLike.value ? ((baseline.value.path as string | undefined) ?? '') : ''))
77
-
78
- // Undo/redo: snapshot history of the whole form state. Every edit funnels through setField (block-tree
79
- // ops route through setField('content', …)), so that's the single capture point. A rapid burst of edits
80
- // to the same field coalesces into one step; the stacks reset on load/save (no undo across a save).
81
- const past = ref<Record<string, unknown>[]>([])
82
- const future = ref<Record<string, unknown>[]>([])
83
- const canUndo = computed(() => past.value.length > 0)
84
- const canRedo = computed(() => future.value.length > 0)
85
- let coalesceKey = ''
86
- let coalesceAt = 0
87
- const COALESCE_MS = 600
88
- const HISTORY_LIMIT = 100
89
-
90
- function recordHistory(name: string) {
91
- const now = Date.now()
92
- const coalesce = name === coalesceKey && now - coalesceAt < COALESCE_MS
93
- coalesceKey = name
94
- coalesceAt = now
95
- if (coalesce) return
96
- past.value.push(snapshot(values))
97
- if (past.value.length > HISTORY_LIMIT) past.value.shift()
98
- future.value = []
99
- }
100
-
101
- function restore(snap: Record<string, unknown>) {
102
- const fresh = snapshot(snap)
103
- for (const k of Object.keys(fresh)) values[k] = fresh[k]
104
- for (const k of Object.keys(values)) if (!(k in fresh)) Reflect.deleteProperty(values, k)
105
- for (const k of Object.keys(errors)) Reflect.deleteProperty(errors, k)
106
- blockErrors.value = {}
107
- formError.value = ''
108
- coalesceKey = ''
109
- }
110
-
111
- function undo() {
112
- if (!past.value.length) return
113
- future.value.push(snapshot(values))
114
- restore(past.value.pop()!)
115
- }
116
- function redo() {
117
- if (!future.value.length) return
118
- past.value.push(snapshot(values))
119
- restore(future.value.pop()!)
120
- }
121
-
122
- const renderableFields = computed<Record<string, SerializedField>>(() => {
123
- if (!blocksEnabled.value) return fields.value
124
- const { content: _content, ...rest } = fields.value
125
- return rest
126
- })
127
-
128
- function fieldKeys() {
129
- return Object.keys(fields.value)
130
- }
131
-
132
- function rebaseline(source: Record<string, unknown> | null) {
133
- const next = initialValues(fields.value)
134
- if (source) {
135
- for (const k of fieldKeys()) {
136
- const col = jsKey(k, fields.value[k]!)
137
- if (col in source) next[k] = source[col]
138
- }
139
- }
140
- // `content` is a synthesized blocks column, not a def.field — round-trip it explicitly.
141
- if (blocksEnabled.value) next.content = (source?.content as unknown[]) ?? []
142
- // `path` (the page slug) is a pageLike system column, likewise round-tripped explicitly.
143
- if (pageLike.value) next.path = (source?.path as string | null | undefined) ?? ''
144
- // '' is the "no override" form the select binds to.
145
- if (pageLike.value) next.layout = (source?.layout as string | null | undefined) ?? ''
146
- // `seo` is a JSON system column; default to an empty object so the editor can fill it in.
147
- if (hasSeo.value) next.seo = (source?.seo as Record<string, unknown> | undefined) ?? {}
148
- // `status` is a system column; a new record defaults to 'draft' (unpublished) — matches the DB default.
149
- if (hasStatus.value) next.status = (source?.status as string | undefined) ?? 'draft'
150
- for (const k of Object.keys(next)) values[k] = next[k]
151
- baseline.value = snapshot(values)
152
- // The optimistic-concurrency baseline: the `updatedAt` this load/save established, sent back on the
153
- // next save so a stale tab (loaded before someone else saved) is refused with 409 instead of
154
- // silently reverting their change. undefined for a never-saved new record.
155
- const stamp = source?.updatedAt
156
- baseUpdatedAt.value = stamp != null ? new Date(stamp as string | number).getTime() : undefined
157
- // A fresh load or a save establishes a new baseline — history does not span it.
158
- past.value = []
159
- future.value = []
160
- coalesceKey = ''
161
- }
162
-
163
- async function init() {
164
- const all = await useCollections().load()
165
- const schema = all.find((c) => c.name === collection)
166
- if (!schema) throw createError({ statusCode: 404, statusMessage: `Unknown collection: ${collection}` })
167
- fields.value = schema.fields
168
- fieldLayout.value = schema.fieldLayout
169
- mode.value = schema.mode
170
- translatable.value = schema.translatable
171
- blocksEnabled.value = schema.blocks?.enabled ?? false
172
- blocksAllowed.value = schema.blocks?.allowed
173
- pageLike.value = schema.pageLike ?? false
174
- hasSeo.value = schema.seo ?? false
175
- hasStatus.value = schema.status ?? false
176
- editorType.value = (schema as { editor?: string }).editor ?? (schema.blocks?.enabled ? 'blocks' : 'fields')
177
-
178
- let row: Record<string, unknown> | null = null
179
- if (mode.value === 'single') {
180
- row = await $fetch<Record<string, unknown> | null>(`/api/${collection}/readOne`, translatable.value ? { query: { locale: requestedLocale } } : {})
181
- activeLocale.value = requestedLocale
182
- } else if (id !== 'new') {
183
- row = await $fetch<Record<string, unknown>>(`/api/${collection}/readOne/${id}`)
184
- // A quarantined row carries none of its normal fields — skip the locale/translations lookups, which
185
- // assume a real row, and go straight to the read-only lock.
186
- if (row?.$quarantined !== true && translatable.value) {
187
- if (typeof row?.locale === 'string') activeLocale.value = row.locale
188
- if (typeof row?.translationGroup === 'string') translationGroup.value = row.translationGroup
189
- // Supplementary (drives the LocaleBar) — never block editing if it fails.
190
- try {
191
- translations.value = await $fetch<Record<string, number | null>>(`/api/${collection}/translations/${id}`)
192
- } catch {
193
- translations.value = {}
194
- }
195
- }
196
- } else {
197
- activeLocale.value = requestedLocale
198
- // A new sibling translation has no id yet, so its locale→id map is resolved by GROUP. Without it the
199
- // LocaleBar shows every already-translated locale as a "+" create link: the copy-from source is
200
- // unreachable, and following that link saves a second row for an occupied locale — a duplicate-locale
201
- // 409 the user can't resolve. Supplementary (drives the LocaleBar) — never block editing if it fails.
202
- if (translatable.value && translationGroup.value) {
203
- try {
204
- translations.value = await $fetch<Record<string, number | null>>(`/api/${collection}/translations`, { query: { group: translationGroup.value } })
205
- } catch {
206
- translations.value = {}
207
- }
208
- }
209
- }
210
- quarantined.value = row?.$quarantined === true
211
- rebaseline(row)
212
-
213
- // Supplementary dead-reference map (the editor warnings) — for any saved record, never blocks editing.
214
- const recordId = typeof row?.id === 'number' ? row.id : null
215
- if (recordId != null && !quarantined.value) {
216
- try {
217
- deadRefs.value = await $fetch<DeadRef[]>(`/api/${collection}/deadRefs/${recordId}`)
218
- } catch {
219
- deadRefs.value = []
220
- }
221
- } else {
222
- deadRefs.value = [] // a new (unsaved) or quarantined record has no references — keep it explicit
223
- }
224
- }
225
-
226
- // `coalesceAs` lets a caller opt out of the default same-name coalescing: block-tree structural ops
227
- // (remove/move/duplicate/add) all round-trip through setField('content', …) like a text edit does, so
228
- // without a distinct key a delete landing inside another edit's 600ms burst would merge into it and
229
- // become impossible to undo on its own.
230
- function setField(name: string, value: unknown, coalesceAs: string = name) {
231
- // Capture the pre-edit state for undo (coalesced for a same-field typing burst).
232
- recordHistory(coalesceAs)
233
- // Reconcile before overwriting: keep block errors/dead-ref warnings through a reorder, drop them once
234
- // the offending field itself is edited (swapping out a broken reference clears its own warning without
235
- // a reload — the dead-ref map is otherwise only refetched on load).
236
- if (name === 'content') {
237
- blockErrors.value = reconcileBlockErrors(blockErrors.value, values.content as unknown[], value as unknown[])
238
- deadRefs.value = reconcileDeadRefs(deadRefs.value, values.content as unknown[], value as unknown[])
239
- } else if (deadRefs.value.some((r) => !r.blockId && r.field === name)) {
240
- deadRefs.value = deadRefs.value.filter((r) => r.blockId || r.field !== name)
241
- }
242
- values[name] = value
243
- formError.value = ''
244
- const field = fields.value[name]
245
- if (field) errors[name] = isFieldVisible(field, values) ? (validateField(asFieldDef(field), value) ?? '') : ''
246
- // A controller edit can hide sibling fields — drop their now-irrelevant advisory errors so a
247
- // hidden required field never shows a phantom error or blocks the save.
248
- for (const [n, f] of Object.entries(fields.value)) if (!isFieldVisible(f, values)) errors[n] = ''
249
- }
250
-
251
- /** Copy another locale's field/content values into this form (the LocaleBar "copy" action). Goes
252
- * through setField so validation, block-error reconcile, and dirty-tracking all apply. */
253
- function applyFrom(source: Record<string, unknown>) {
254
- for (const k of fieldKeys()) {
255
- const col = jsKey(k, fields.value[k]!)
256
- if (col in source) setField(k, source[col])
257
- }
258
- if (blocksEnabled.value && 'content' in source) setField('content', source.content)
259
- }
260
-
261
- function validateAll(): boolean {
262
- let ok = true
263
- for (const [name, field] of Object.entries(fields.value)) {
264
- // A hidden field is exempt from validation (its `required` only applies when shown) — mirrors
265
- // the server's required-when-visible enforcement, so the client never blocks a valid save.
266
- if (!isFieldVisible(field, values)) { errors[name] = ''; continue }
267
- const msg = validateField(asFieldDef(field), values[name])
268
- errors[name] = msg ?? ''
269
- if (msg) ok = false
270
- }
271
- return ok
272
- }
273
-
274
- function buildBody(): Record<string, unknown> {
275
- const body: Record<string, unknown> = {}
276
- for (const k of fieldKeys()) body[jsKey(k, fields.value[k]!)] = values[k]
277
- if (blocksEnabled.value) body.content = values.content
278
- // Send the slug as the routable path; a blank slug clears the route (stored as null, not "").
279
- if (pageLike.value) body.path = (values.path as string) ? values.path : null
280
- // An unset layout is stored as NULL, never '': the render coalesces NULL to `default`, and a stored ''
281
- // would be indistinguishable from a name that failed to save.
282
- if (pageLike.value) body.layout = (values.layout as string) || null
283
- if (hasSeo.value) body.seo = values.seo ?? {}
284
- if (hasStatus.value) body.status = (values.status as string) ?? 'draft'
285
- // A new translatable multi record carries its locale, and links to a translation group when it is
286
- // a sibling translation. Existing rows keep their locale; the singleton uses the ?locale query.
287
- if (translatable.value && mode.value === 'multi' && id === 'new') {
288
- body.locale = activeLocale.value
289
- if (translationGroup.value) body.translationGroup = translationGroup.value
290
- }
291
- return body
292
- }
293
-
294
- function handleError(e: unknown) {
295
- const { statusCode, statusMessage, issues, data } = readFetchError(e)
296
- // The row WAS written and a critical after-step failed (e.g. `writeRedirects`), so the server hands
297
- // back the new `updatedAt`. Take it as the baseline: the client only rebaselines on success, and
298
- // without this the "save again" those errors ask for would 409 on a stale precondition.
299
- if (typeof data?.savedUpdatedAt === 'number') baseUpdatedAt.value = data.savedUpdatedAt
300
- if (statusCode === 400) {
301
- const mapped = mapServerErrors(issues)
302
- for (const [k, msg] of Object.entries(mapped.fields)) errors[k] = msg
303
- // `content` errors are nested (content[i].props.x) and have no flat field to render them,
304
- // so surface a form-level banner instead of failing silently.
305
- blockErrors.value = parseBlockErrors(issues, values.content as unknown[])
306
- // A field error (e.g. the page slug) renders inline in PageFields, but that pane is unmounted while a
307
- // block is selected — so also raise a banner whenever there are field errors, never a silent failure.
308
- formError.value = mapped.form
309
- ?? (mapped.fields.content
310
- ? t('editor.fixBlockContent')
311
- : Object.keys(mapped.fields).length ? t('editor.fixPageFields') : '')
312
- } else if (statusCode === 409) {
313
- // `||`, not `??`: an absent reason phrase arrives as '' rather than undefined, and an empty banner
314
- // is a silent failure — the Save button simply stops spinning.
315
- formError.value = statusMessage || t('editor.saveConflict')
316
- } else {
317
- formError.value = statusMessage || t('editor.saveFailed')
318
- }
319
- }
320
-
321
- async function submit(): Promise<SubmitResult> {
322
- // Sealed at the wire (validateOut) — a quarantined row's fields are unknown, so nothing here may
323
- // send a write for it.
324
- if (quarantined.value) return { ok: false }
325
- formError.value = ''
326
- blockErrors.value = {}
327
- // Clear the whole map, not just declared-field errors: `path`/`seo`/`status` are system columns
328
- // (never in `fields.value`), so a stale server error on one of them would otherwise survive a
329
- // later successful save. `validateAll` below repopulates every declared-field error fresh.
330
- for (const k of Object.keys(errors)) Reflect.deleteProperty(errors, k)
331
- // Client-side validation failed: surface the same banner the server path raises, so the failure is
332
- // never silent — the offending field's inline error can sit in an unmounted pane (blocks editor).
333
- if (!validateAll()) {
334
- formError.value = t('editor.fixPageFields')
335
- return { ok: false }
336
- }
337
- saving.value = true
338
- try {
339
- const body = buildBody()
340
- // The optimistic-concurrency precondition: send the baseline `updatedAt` so a stale overwrite 409s
341
- // instead of silently reverting a concurrent save. Omitted for a brand-new record (no baseline).
342
- const ifUnmodified: Record<string, string> = baseUpdatedAt.value !== undefined ? { 'x-kestrel-if-unmodified-since': String(baseUpdatedAt.value) } : {}
343
- // `method` cast: Nuxt's typed `$fetch` over-constrains the method for this dynamic (template-literal)
344
- // admin route to GET; the runtime value is correct, only the static route type can't express it.
345
- const row = mode.value === 'single'
346
- ? await $fetch(`/api/${collection}/updateOne`, { method: 'POST' as never, body, headers: ifUnmodified, ...(translatable.value ? { query: { locale: activeLocale.value } } : {}) })
347
- : id === 'new'
348
- ? await $fetch(`/api/${collection}/createOne`, { method: 'POST' as never, body })
349
- : await $fetch(`/api/${collection}/updateOne/${id}`, { method: 'POST' as never, body, headers: ifUnmodified })
350
- rebaseline(row as Record<string, unknown>)
351
- // `record` is the saved entity at the editor's public boundary; `row` stays the DB-layer term.
352
- return { ok: true, record: row }
353
- } catch (e) {
354
- handleError(e)
355
- return { ok: false }
356
- } finally {
357
- saving.value = false
358
- }
359
- }
360
-
361
- const ready = init()
362
-
363
- return {
364
- collection,
365
- locale: activeLocale,
366
- translations,
367
- deadRefs,
368
- translationGroup,
369
- applyFrom,
370
- mode,
371
- fields,
372
- fieldLayout,
373
- renderableFields,
374
- translatable,
375
- blocksEnabled,
376
- blocksAllowed,
377
- editorType,
378
- pageLike,
379
- hasSeo,
380
- hasStatus,
381
- quarantined,
382
- values,
383
- errors,
384
- formError,
385
- blockErrors,
386
- dirty,
387
- savedStatus,
388
- savedPath,
389
- saving,
390
- ready,
391
- setField,
392
- validateAll,
393
- // The wire body a save would send — also what a preview ticket carries, so an external tab renders
394
- // exactly what a save would have stored (ADR-0008).
395
- buildBody,
396
- submit,
397
- undo,
398
- redo,
399
- canUndo,
400
- canRedo,
401
- }
402
- }
@@ -1,40 +0,0 @@
1
- import { reactive } from 'vue'
2
-
3
- /**
4
- * Batched, cached client resolution of internal-link refs (`{collection, id}`) → public href,
5
- * mirroring the server `resolveInternalHref` so the live preview's `KestrelLink` renders real internal
6
- * paths instead of `'#'`. Per instance (no shared module state → SSR-safe). No locale param: an
7
- * internal link points to one specific record, whose href is its own locale's path. Misses are cached
8
- * (empty string) so a dangling (missing-target) ref isn't re-fetched on every preview recompute.
9
- */
10
- export function useLinkResolver() {
11
- const cache = reactive(new Map<string, string>())
12
- const pending = new Set<string>()
13
- const keyFor = (collection: string, id: number) => `${collection}:${id}`
14
-
15
- async function ensure(refs: { collection: string; id: number }[]): Promise<void> {
16
- const missing = refs.filter((r) =>
17
- !!r.collection && Number.isInteger(r.id) && r.id > 0
18
- && !cache.has(keyFor(r.collection, r.id)) && !pending.has(keyFor(r.collection, r.id)))
19
- if (!missing.length) return
20
- const keys = missing.map((r) => keyFor(r.collection, r.id))
21
- for (const k of keys) pending.add(k)
22
- try {
23
- const { data } = await $fetch<{ data: { collection: string; id: number; href: string }[] }>(
24
- '/api/resolveLinks', { query: { refs: keys.join(',') } },
25
- )
26
- const got = new Map(data.map((d) => [keyFor(d.collection, d.id), d.href]))
27
- for (const k of keys) cache.set(k, got.get(k) ?? '') // cache misses too, as '' → no re-fetch
28
- } catch {
29
- // leave unresolved — the preview shows '#' for these until a later attempt
30
- } finally {
31
- for (const k of keys) pending.delete(k)
32
- }
33
- }
34
-
35
- function resolve(collection: string, id: number): string | null {
36
- return cache.get(keyFor(collection, id)) || null
37
- }
38
-
39
- return { cache, ensure, resolve }
40
- }
@@ -1,78 +0,0 @@
1
- // Row and bulk actions for the collection list: a row action is one id, the bulk bar is N — the server
2
- // does the batching, so both go through the same calls.
3
- //
4
- // Destructive → confirm dialog. Reversible → run, toast, refetch (the ops composable's onChanged).
5
- import { ref, type ComputedRef } from 'vue'
6
- import { resolveLocalized } from '../../../ui/app/utils/localized'
7
- import type { BatchDeleteReport } from '../utils/collection-ops'
8
- import type { SerializedAction } from '@michaelthielemann/kestrel-core'
9
-
10
- export function useListBatchActions(collection: ComputedRef<string>, refetch: () => void | Promise<void>) {
11
- const { t, lang } = useT()
12
- const toast = useToast()
13
- const { busy, error, previewDelete, confirmDelete: runDelete, duplicate: runDuplicate, setStatus: runSetStatus, runCustomAction } =
14
- useCollectionOps(collection, () => refetch())
15
-
16
- const deleteOpen = ref(false)
17
- const deleteReport = ref<BatchDeleteReport | null>(null)
18
- const deleteIds = ref<number[]>([])
19
-
20
- // The referrer preview is best-effort: if it fails we still open the dialog with a bare summary (never
21
- // block a delete on the warning lookup).
22
- async function askDelete(ids: number[]) {
23
- if (!ids.length) return
24
- deleteIds.value = ids
25
- deleteReport.value = null
26
- deleteOpen.value = true
27
- try {
28
- deleteReport.value = await previewDelete(ids)
29
- } catch {
30
- // The referrer lookup FAILED — do not fabricate a "no inbound links" report (that would look exactly
31
- // like a verified-safe delete). Flag `checked: false` so the dialog cautions the check couldn't run.
32
- deleteReport.value = { count: ids.length, referencedCount: 0, referenced: [], checked: false }
33
- error.value = null
34
- }
35
- }
36
- async function confirmDelete() {
37
- try {
38
- await runDelete(deleteIds.value)
39
- toast.success(t('toast.deleted'))
40
- deleteOpen.value = false
41
- } catch {
42
- // error stays surfaced in the dialog via `error`
43
- }
44
- }
45
- async function duplicate(id: number) {
46
- try {
47
- await runDuplicate([id])
48
- toast.success(t('toast.duplicated'))
49
- } catch {
50
- toast.error(error.value ?? t('list.opFailed'))
51
- }
52
- }
53
- async function setStatus(ids: number[], status: 'published' | 'draft') {
54
- try {
55
- await runSetStatus(ids, status)
56
- toast.success(status === 'published' ? t('toast.published') : t('toast.unpublished'))
57
- } catch {
58
- toast.error(error.value ?? t('list.opFailed'))
59
- }
60
- }
61
-
62
- /** A schema-driven custom action: label falls back to the pipeline name, and an `ui.confirm` flag gates
63
- * on a plain confirm prompt (the built-ins get the richer dialog above; a generic action has no report to
64
- * preview). */
65
- async function runAction(action: SerializedAction, ids: number[]) {
66
- if (!ids.length) return
67
- const label = resolveLocalized(action.label, lang.value) ?? action.name
68
- if (action.confirm && !confirm(t('list.actionConfirm', { name: label, n: ids.length }))) return
69
- try {
70
- await runCustomAction(action, ids)
71
- toast.success(t('list.actionDone', { name: label }))
72
- } catch {
73
- toast.error(error.value ?? t('list.opFailed'))
74
- }
75
- }
76
-
77
- return { busy, error, deleteOpen, deleteReport, askDelete, confirmDelete, duplicate, setStatus, runAction }
78
- }
@@ -1,53 +0,0 @@
1
- import type { Ref } from 'vue'
2
- import type { SerializedCollection } from '@michaelthielemann/kestrel-core'
3
- import { availableColumns, defaultVisibleKeys, resolveVisibleColumns, type ListColumn } from '../utils/list-columns'
4
-
5
- /**
6
- * Per-collection list column visibility, persisted in a cookie (SSR-safe, like the rail-collapsed state).
7
- * The store maps collection name → the chosen visible keys; an absent entry falls back to the default set.
8
- * Stale keys (a since-removed field) are dropped on read via `resolveVisibleColumns`.
9
- */
10
- export function useListColumns(schema: Ref<SerializedCollection>) {
11
- const store = useCookie<Record<string, string[]>>('kestrel-list-columns', { default: () => ({}) })
12
-
13
- const available = computed<ListColumn[]>(() => availableColumns(schema.value))
14
- const defaults = computed<string[]>(() => defaultVisibleKeys(schema.value))
15
-
16
- const visibleKeys = computed<string[]>(() => {
17
- const stored = store.value[schema.value.name]
18
- // Project through the available columns so order is canonical and removed columns disappear.
19
- return resolveVisibleColumns(available.value, stored ?? defaults.value).map((c) => c.key)
20
- })
21
- const visibleColumns = computed<ListColumn[]>(() => resolveVisibleColumns(available.value, visibleKeys.value))
22
-
23
- function persist(keys: string[]) {
24
- // Keep canonical order so persisted prefs round-trip stably.
25
- const ordered = available.value.filter((c) => keys.includes(c.key)).map((c) => c.key)
26
- store.value = { ...store.value, [schema.value.name]: ordered }
27
- }
28
-
29
- function toggle(key: string) {
30
- const next = new Set(visibleKeys.value)
31
- if (next.has(key)) {
32
- if (next.size <= 1) return // never hide the last column
33
- next.delete(key)
34
- } else {
35
- next.add(key)
36
- }
37
- persist([...next])
38
- }
39
-
40
- function reset() {
41
- const next = { ...store.value }
42
- Reflect.deleteProperty(next, schema.value.name)
43
- store.value = next
44
- }
45
-
46
- const isDefault = computed(() => {
47
- const a = visibleKeys.value
48
- const b = defaults.value
49
- return a.length === b.length && a.every((k, i) => k === b[i])
50
- })
51
-
52
- return { available, visibleColumns, visibleKeys, toggle, reset, isDefault }
53
- }