@michaelthielemann/kestrel 4.1.3 → 5.0.2

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 (342) hide show
  1. package/README.md +26 -201
  2. package/dist/boot.d.ts +45 -0
  3. package/dist/boot.js +284 -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 +162 -0
  15. package/dist/defineConfig.js +42 -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/allowlist.d.ts +9 -0
  43. package/dist/triggers/allowlist.js +49 -0
  44. package/dist/triggers/cron.d.ts +10 -0
  45. package/dist/triggers/cron.js +44 -0
  46. package/dist/triggers/cronMatch.d.ts +10 -0
  47. package/dist/triggers/cronMatch.js +68 -0
  48. package/dist/triggers/http.d.ts +59 -0
  49. package/dist/triggers/http.js +300 -0
  50. package/package.json +25 -151
  51. package/NOTICE +0 -12
  52. package/kestrel.config.ts +0 -28
  53. package/layers/access/nuxt.config.ts +0 -5
  54. package/layers/access/server/middleware/00.ip-allowlist.ts +0 -52
  55. package/layers/access/server/middleware/access-guard.ts +0 -32
  56. package/layers/access/server/types.d.ts +0 -7
  57. package/layers/admin/app/components/AdminAccount.vue +0 -212
  58. package/layers/admin/app/components/AdminNav.vue +0 -44
  59. package/layers/admin/app/components/BlockFields.vue +0 -67
  60. package/layers/admin/app/components/BlockPreview.vue +0 -413
  61. package/layers/admin/app/components/BlockTree.vue +0 -433
  62. package/layers/admin/app/components/BlocksBody.vue +0 -163
  63. package/layers/admin/app/components/CollectionDeleteDialog.vue +0 -38
  64. package/layers/admin/app/components/CollectionEditor.vue +0 -324
  65. package/layers/admin/app/components/CollectionList.vue +0 -429
  66. package/layers/admin/app/components/CollectionListBulkBar.vue +0 -62
  67. package/layers/admin/app/components/CollectionListColumnsPanel.vue +0 -37
  68. package/layers/admin/app/components/CollectionListFilterPanel.vue +0 -145
  69. package/layers/admin/app/components/CollectionListPager.vue +0 -70
  70. package/layers/admin/app/components/CollectionListTable.vue +0 -347
  71. package/layers/admin/app/components/EditorStatus.vue +0 -211
  72. package/layers/admin/app/components/EditorUnsupported.vue +0 -28
  73. package/layers/admin/app/components/FieldsBody.vue +0 -23
  74. package/layers/admin/app/components/LocaleBar.vue +0 -107
  75. package/layers/admin/app/components/PageFields.vue +0 -129
  76. package/layers/admin/app/components/PageFieldsPane.vue +0 -39
  77. package/layers/admin/app/components/SeoFields.vue +0 -210
  78. package/layers/admin/app/components/SingletonEditor.vue +0 -93
  79. package/layers/admin/app/composables/useAuth.ts +0 -68
  80. package/layers/admin/app/composables/useBlockTree.ts +0 -95
  81. package/layers/admin/app/composables/useBlocks.ts +0 -7
  82. package/layers/admin/app/composables/useCachedResource.ts +0 -35
  83. package/layers/admin/app/composables/useCollectionOps.ts +0 -102
  84. package/layers/admin/app/composables/useCollections.ts +0 -6
  85. package/layers/admin/app/composables/useContentLocales.ts +0 -23
  86. package/layers/admin/app/composables/useEditForm.ts +0 -402
  87. package/layers/admin/app/composables/useLinkResolver.ts +0 -40
  88. package/layers/admin/app/composables/useListBatchActions.ts +0 -78
  89. package/layers/admin/app/composables/useListColumns.ts +0 -53
  90. package/layers/admin/app/composables/useListFilterDraft.ts +0 -129
  91. package/layers/admin/app/composables/useListRows.ts +0 -75
  92. package/layers/admin/app/composables/useListSelection.ts +0 -28
  93. package/layers/admin/app/composables/useListUrlState.ts +0 -53
  94. package/layers/admin/app/composables/usePendingPublishPoll.ts +0 -15
  95. package/layers/admin/app/composables/usePreviewViewport.ts +0 -30
  96. package/layers/admin/app/composables/usePublishStatus.ts +0 -107
  97. package/layers/admin/app/composables/useRailCollapsed.ts +0 -14
  98. package/layers/admin/app/composables/useTheme.ts +0 -36
  99. package/layers/admin/app/composables/useToolbarPanel.ts +0 -38
  100. package/layers/admin/app/composables/useUnsavedGuard.ts +0 -22
  101. package/layers/admin/app/layouts/admin.vue +0 -266
  102. package/layers/admin/app/middleware/admin-auth.ts +0 -4
  103. package/layers/admin/app/pages/admin/[collection]/[id].vue +0 -203
  104. package/layers/admin/app/pages/admin/[collection]/index.vue +0 -62
  105. package/layers/admin/app/pages/admin/index.vue +0 -118
  106. package/layers/admin/app/pages/admin/login.vue +0 -68
  107. package/layers/admin/app/pages/admin/media.vue +0 -28
  108. package/layers/admin/app/pages/admin/references.vue +0 -146
  109. package/layers/admin/app/plugins/admin-style-guard.client.ts +0 -76
  110. package/layers/admin/app/plugins/reauth.client.ts +0 -20
  111. package/layers/admin/app/utils/admin-style-guard.ts +0 -20
  112. package/layers/admin/app/utils/block-tree.ts +0 -214
  113. package/layers/admin/app/utils/collection-ops.ts +0 -69
  114. package/layers/admin/app/utils/dead-refs.ts +0 -30
  115. package/layers/admin/app/utils/edit-form.ts +0 -267
  116. package/layers/admin/app/utils/editor-expose.ts +0 -36
  117. package/layers/admin/app/utils/editor-form-context.ts +0 -82
  118. package/layers/admin/app/utils/editor-registry.ts +0 -20
  119. package/layers/admin/app/utils/field-keys.ts +0 -11
  120. package/layers/admin/app/utils/list-cell.ts +0 -46
  121. package/layers/admin/app/utils/list-columns.ts +0 -142
  122. package/layers/admin/app/utils/list-query.ts +0 -96
  123. package/layers/admin/app/utils/nav-active.ts +0 -9
  124. package/layers/admin/app/utils/populate-blocks.ts +0 -288
  125. package/layers/admin/app/utils/preview-channel.ts +0 -62
  126. package/layers/admin/app/utils/preview-viewport.ts +0 -80
  127. package/layers/admin/app/utils/reauth.ts +0 -67
  128. package/layers/admin/app/utils/record-title.ts +0 -15
  129. package/layers/admin/app/utils/register-builtin-editors.ts +0 -9
  130. package/layers/admin/app/utils/safe-redirect.ts +0 -12
  131. package/layers/admin/nuxt.config.ts +0 -20
  132. package/layers/auth/nuxt.config.ts +0 -1
  133. package/layers/auth/server/plugins/01.register-auth-pipelines.ts +0 -7
  134. package/layers/collections/nuxt.config.ts +0 -1
  135. package/layers/collections/server/plugins/01.register.ts +0 -29
  136. package/layers/collections/server/plugins/02.register-relation-populate.ts +0 -18
  137. package/layers/core/app/composables/layouts.ts +0 -5
  138. package/layers/core/app/utils/field-factories.ts +0 -129
  139. package/layers/core/app/utils/layouts.ts +0 -38
  140. package/layers/core/modules/auto-discovery/extract-block.ts +0 -179
  141. package/layers/core/modules/auto-discovery/index.ts +0 -112
  142. package/layers/core/modules/auto-discovery/package-registry.ts +0 -19
  143. package/layers/core/modules/auto-discovery/scan.ts +0 -170
  144. package/layers/core/modules/auto-discovery/virtual.d.ts +0 -20
  145. package/layers/core/modules/component-namespace/index.ts +0 -44
  146. package/layers/core/modules/component-namespace/shared.ts +0 -24
  147. package/layers/core/modules/kestrel/app-shell.ts +0 -55
  148. package/layers/core/modules/kestrel/auto-imports.ts +0 -44
  149. package/layers/core/modules/kestrel/index.ts +0 -144
  150. package/layers/core/modules/plugin-order/index.ts +0 -69
  151. package/layers/core/modules/plugin-order/plugin-order.ts +0 -273
  152. package/layers/core/nuxt.config.ts +0 -10
  153. package/layers/core/server/api/[...path].ts +0 -98
  154. package/layers/core/server/plugins/00.config.ts +0 -12
  155. package/layers/core/server/plugins/00.migrate.ts +0 -12
  156. package/layers/core/server/plugins/01.register-introspection-pipeline.ts +0 -5
  157. package/layers/core/server/plugins/01.register-openapi-pipeline.ts +0 -5
  158. package/layers/core/server/plugins/01.register-tooling-pipelines.ts +0 -5
  159. package/layers/core/server/plugins/02.schema-sync.ts +0 -58
  160. package/layers/core/server/plugins/04.outbox-worker.ts +0 -31
  161. package/layers/core/server/plugins/05.reindex-refs.ts +0 -5
  162. package/layers/core/server/routes/__kestrel/dashboard.get.ts +0 -29
  163. package/layers/core/server/tasks/db/migrate-module.ts +0 -83
  164. package/layers/core/server/tasks/db/migrate-revisions.ts +0 -20
  165. package/layers/core/server/tasks/db/migrate.ts +0 -50
  166. package/layers/core/server/utils/server-config.ts +0 -81
  167. package/layers/fields/nuxt.config.ts +0 -1
  168. package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +0 -7
  169. package/layers/fields/server/plugins/01.register-field-populate.ts +0 -11
  170. package/layers/media/app/components/KestrelImg.vue +0 -94
  171. package/layers/media/app/components/MediaConflictDialog.vue +0 -35
  172. package/layers/media/app/components/MediaDeleteDialog.vue +0 -52
  173. package/layers/media/app/components/MediaGrid.vue +0 -113
  174. package/layers/media/app/components/MediaImage.vue +0 -70
  175. package/layers/media/app/components/MediaLibrary.vue +0 -456
  176. package/layers/media/app/components/MediaNewFolderDialog.vue +0 -32
  177. package/layers/media/app/components/MediaPathBar.vue +0 -163
  178. package/layers/media/app/components/MediaPicker.vue +0 -19
  179. package/layers/media/app/components/MediaRenameDialog.vue +0 -32
  180. package/layers/media/app/components/MediaTable.vue +0 -75
  181. package/layers/media/app/components/MediaThumb.vue +0 -25
  182. package/layers/media/app/components/MediaToolbar.vue +0 -90
  183. package/layers/media/app/components/MediaUploadDialog.vue +0 -87
  184. package/layers/media/app/components/MediaViewer.vue +0 -137
  185. package/layers/media/app/components/field/Media.vue +0 -263
  186. package/layers/media/app/composables/useBlurUp.ts +0 -55
  187. package/layers/media/app/composables/useMediaClipboard.ts +0 -14
  188. package/layers/media/app/composables/useMediaContextMenu.ts +0 -56
  189. package/layers/media/app/composables/useMediaDnd.ts +0 -56
  190. package/layers/media/app/composables/useMediaLibrary.ts +0 -152
  191. package/layers/media/app/composables/useMediaOps.ts +0 -50
  192. package/layers/media/app/composables/useMediaRelocate.ts +0 -39
  193. package/layers/media/app/composables/useMediaResolver.ts +0 -50
  194. package/layers/media/app/composables/useMediaUpload.ts +0 -93
  195. package/layers/media/app/composables/useMediaVariant.ts +0 -34
  196. package/layers/media/app/plugins/field-media.client.ts +0 -6
  197. package/layers/media/app/utils/ai-disclosure.ts +0 -19
  198. package/layers/media/app/utils/dnd.ts +0 -69
  199. package/layers/media/app/utils/library.ts +0 -81
  200. package/layers/media/app/utils/ops.ts +0 -60
  201. package/layers/media/app/utils/picture-model.ts +0 -125
  202. package/layers/media/app/utils/thumbhash.ts +0 -19
  203. package/layers/media/app/utils/variant-name.ts +0 -20
  204. package/layers/media/nuxt.config.ts +0 -7
  205. package/layers/media/server/middleware/ondemand-variants.ts +0 -38
  206. package/layers/media/server/plugins/01.register-media-pipelines.ts +0 -7
  207. package/layers/media/server/plugins/02.register-media.ts +0 -22
  208. package/layers/media/server/plugins/04.variant-capture.ts +0 -27
  209. package/layers/media/server/plugins/05.media-cleanup.ts +0 -5
  210. package/layers/media/server/tasks/media/backfill.ts +0 -17
  211. package/layers/public/app/app.vue +0 -5
  212. package/layers/public/app/components/BlockRenderer.vue +0 -56
  213. package/layers/public/app/components/KestrelLink.vue +0 -26
  214. package/layers/public/app/components/KestrelPreviewBridge.vue +0 -133
  215. package/layers/public/app/composables/public-page.ts +0 -24
  216. package/layers/public/app/error.vue +0 -12
  217. package/layers/public/app/layouts/default.vue +0 -5
  218. package/layers/public/app/pages/[...slug].vue +0 -239
  219. package/layers/public/app/pages/__kestrel/preview.vue +0 -46
  220. package/layers/public/app/utils/block-edit-context.ts +0 -16
  221. package/layers/public/app/utils/json-ld.ts +0 -139
  222. package/layers/public/app/utils/link-href.ts +0 -46
  223. package/layers/public/app/utils/page-head.ts +0 -84
  224. package/layers/public/app/utils/page-layout.ts +0 -18
  225. package/layers/public/app/utils/preview-protocol.ts +0 -117
  226. package/layers/public/app/utils/public-route.ts +0 -46
  227. package/layers/public/app/utils/site-head.ts +0 -43
  228. package/layers/public/modules/deploy-output/index.ts +0 -80
  229. package/layers/public/modules/prerender-routes/discover.ts +0 -37
  230. package/layers/public/modules/prerender-routes/index.ts +0 -88
  231. package/layers/public/modules/prerender-routes/routes.ts +0 -16
  232. package/layers/public/modules/prune-media/index.ts +0 -48
  233. package/layers/public/modules/prune-media/prune-media.ts +0 -81
  234. package/layers/public/nuxt.config.ts +0 -14
  235. package/layers/public/server/delivery-live/serve.ts +0 -111
  236. package/layers/public/server/middleware/delivery-live-catchall.ts +0 -7
  237. package/layers/public/server/pipelines/index.ts +0 -23
  238. package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +0 -14
  239. package/layers/public/server/plugins/01.register-public-pipelines.ts +0 -7
  240. package/layers/public/server/plugins/02.register-links.ts +0 -16
  241. package/layers/public/server/plugins/03.redirects.ts +0 -55
  242. package/layers/public/server/plugins/05.plan-publish.ts +0 -5
  243. package/layers/public/server/plugins/zz.publish.ts +0 -127
  244. package/layers/public/server/routes/llms-full.txt.get.ts +0 -99
  245. package/layers/public/server/routes/llms.txt.get.ts +0 -66
  246. package/layers/public/server/routes/redirects.json.get.ts +0 -58
  247. package/layers/public/server/routes/robots.txt.get.ts +0 -11
  248. package/layers/public/server/routes/sitemap.xml.get.ts +0 -79
  249. package/layers/public/server/tasks/publish/run.ts +0 -32
  250. package/layers/public/server/utils/publish/render-live.ts +0 -26
  251. package/layers/ui/app/assets/scss/_base.scss +0 -16
  252. package/layers/ui/app/assets/scss/_datepicker.scss +0 -82
  253. package/layers/ui/app/assets/scss/_mixins.scss +0 -50
  254. package/layers/ui/app/assets/scss/_reset.scss +0 -65
  255. package/layers/ui/app/assets/scss/_tokens.scss +0 -183
  256. package/layers/ui/app/assets/scss/main.scss +0 -4
  257. package/layers/ui/app/components/field/Boolean.vue +0 -30
  258. package/layers/ui/app/components/field/Choice.vue +0 -73
  259. package/layers/ui/app/components/field/Datetime.vue +0 -98
  260. package/layers/ui/app/components/field/DeadRefNote.vue +0 -28
  261. package/layers/ui/app/components/field/Json.vue +0 -62
  262. package/layers/ui/app/components/field/Layout.vue +0 -138
  263. package/layers/ui/app/components/field/Link.vue +0 -112
  264. package/layers/ui/app/components/field/LinkInternalPicker.vue +0 -95
  265. package/layers/ui/app/components/field/Number.vue +0 -30
  266. package/layers/ui/app/components/field/Relation.vue +0 -49
  267. package/layers/ui/app/components/field/Renderer.vue +0 -30
  268. package/layers/ui/app/components/field/Repeater.vue +0 -460
  269. package/layers/ui/app/components/field/Richtext.vue +0 -26
  270. package/layers/ui/app/components/field/Slug.vue +0 -65
  271. package/layers/ui/app/components/field/Text.vue +0 -35
  272. package/layers/ui/app/components/field/Unsupported.vue +0 -34
  273. package/layers/ui/app/components/ui/Alert.vue +0 -50
  274. package/layers/ui/app/components/ui/Brand.vue +0 -22
  275. package/layers/ui/app/components/ui/Button.vue +0 -116
  276. package/layers/ui/app/components/ui/ButtonGroup.vue +0 -96
  277. package/layers/ui/app/components/ui/Checkbox.vue +0 -53
  278. package/layers/ui/app/components/ui/CheckboxGroup.vue +0 -51
  279. package/layers/ui/app/components/ui/Combobox.vue +0 -345
  280. package/layers/ui/app/components/ui/DatePicker.vue +0 -92
  281. package/layers/ui/app/components/ui/DateRangePicker.vue +0 -100
  282. package/layers/ui/app/components/ui/Dialog.vue +0 -107
  283. package/layers/ui/app/components/ui/EmptyState.vue +0 -46
  284. package/layers/ui/app/components/ui/Field.vue +0 -58
  285. package/layers/ui/app/components/ui/Fieldset.vue +0 -65
  286. package/layers/ui/app/components/ui/Icon.vue +0 -44
  287. package/layers/ui/app/components/ui/Menu.vue +0 -54
  288. package/layers/ui/app/components/ui/NumberInput.vue +0 -175
  289. package/layers/ui/app/components/ui/Richtext.vue +0 -141
  290. package/layers/ui/app/components/ui/RichtextToolbar.vue +0 -251
  291. package/layers/ui/app/components/ui/Select.vue +0 -52
  292. package/layers/ui/app/components/ui/TextInput.vue +0 -111
  293. package/layers/ui/app/components/ui/Textarea.vue +0 -39
  294. package/layers/ui/app/components/ui/TimeInput.vue +0 -18
  295. package/layers/ui/app/components/ui/Toasts.vue +0 -103
  296. package/layers/ui/app/components/ui/Tooltip.vue +0 -45
  297. package/layers/ui/app/composables/useAdminLang.ts +0 -8
  298. package/layers/ui/app/composables/useDragReorder.ts +0 -41
  299. package/layers/ui/app/composables/useEchoGuard.ts +0 -28
  300. package/layers/ui/app/composables/useLinkField.ts +0 -70
  301. package/layers/ui/app/composables/useRecordOptions.ts +0 -84
  302. package/layers/ui/app/composables/useRepeater.ts +0 -99
  303. package/layers/ui/app/composables/useT.ts +0 -32
  304. package/layers/ui/app/composables/useToast.ts +0 -46
  305. package/layers/ui/app/i18n/de.ts +0 -432
  306. package/layers/ui/app/i18n/en.ts +0 -434
  307. package/layers/ui/app/utils/boolean-field.ts +0 -10
  308. package/layers/ui/app/utils/field-component.ts +0 -22
  309. package/layers/ui/app/utils/field-empty.ts +0 -21
  310. package/layers/ui/app/utils/field-registry.ts +0 -36
  311. package/layers/ui/app/utils/humanize.ts +0 -14
  312. package/layers/ui/app/utils/icons.ts +0 -143
  313. package/layers/ui/app/utils/iso-date.ts +0 -18
  314. package/layers/ui/app/utils/localized.ts +0 -13
  315. package/layers/ui/app/utils/record-context.ts +0 -15
  316. package/layers/ui/app/utils/reorder.ts +0 -8
  317. package/layers/ui/app/utils/richtext-preserve-class.ts +0 -31
  318. package/layers/ui/app/utils/useFieldA11y.ts +0 -40
  319. package/layers/ui/nuxt.config.ts +0 -9
  320. package/nuxt.config.ts +0 -108
  321. package/scripts/consumer-template-ci.mjs +0 -160
  322. package/scripts/copy-create-payload.mjs +0 -49
  323. package/scripts/coverage-all.mjs +0 -163
  324. package/scripts/dashboard.mjs +0 -89
  325. package/scripts/hash-password.mjs +0 -29
  326. package/scripts/kestrel.mjs +0 -240
  327. package/scripts/lib/cli.mjs +0 -129
  328. package/scripts/lib/coverage-model.mjs +0 -51
  329. package/scripts/lib/password.mjs +0 -19
  330. package/scripts/lib/scaffold.mjs +0 -205
  331. package/scripts/port-graph.mjs +0 -350
  332. package/scripts/typecheck.mjs +0 -62
  333. package/templates/starter/README.md +0 -65
  334. package/templates/starter/_env.example +0 -17
  335. package/templates/starter/_gitignore +0 -26
  336. package/templates/starter/_package.json +0 -22
  337. package/templates/starter/app/app.vue +0 -7
  338. package/templates/starter/app/blocks/Prose.vue +0 -13
  339. package/templates/starter/app/layouts/default.vue +0 -6
  340. package/templates/starter/nuxt.config.ts +0 -23
  341. package/templates/starter/pnpm-workspace.yaml +0 -8
  342. package/templates/starter/tsconfig.json +0 -3
@@ -1,129 +0,0 @@
1
- // The Filter panel's pending edits, and how they become committed clauses in the URL.
2
- //
3
- // `filter` (from the URL) is the committed state that drives the query; `draft` is what the panel shows
4
- // while the user is still typing. A value change debounces, an operator change commits at once.
5
- import { computed, onBeforeUnmount, reactive, watch, type ComputedRef } from 'vue'
6
- import { DEFAULT_OP, OPS_BY_KIND } from '@michaelthielemann/kestrel-core/client'
7
- import type { FilterKind, FilterOp } from '@michaelthielemann/kestrel-core/client'
8
- import type { Localized } from '@michaelthielemann/kestrel-core'
9
- import { resolveLocalized } from '../../../ui/app/utils/localized'
10
- import { columnLabel } from '../utils/list-cell'
11
- import type { ListColumn } from '../utils/list-columns'
12
- import type { FilterCell } from '../utils/list-query'
13
-
14
- const COMMIT_DEBOUNCE_MS = 250
15
-
16
- /** One active clause as the chip row renders it. */
17
- export interface ActiveFilter {
18
- key: string
19
- op: FilterOp
20
- opLabel: string
21
- label: string
22
- display: string
23
- }
24
-
25
- /** The panel's whole controller — passed to the filter panel as one prop rather than a dozen. */
26
- export interface ListFilterDraft {
27
- draft: Record<string, FilterCell>
28
- activeFilters: ComputedRef<ActiveFilter[]>
29
- opsFor: (c: ListColumn) => readonly FilterOp[]
30
- opLabel: (kind: FilterKind, op: FilterOp) => string
31
- enumOptions: (c: ListColumn) => { label: string; value: string }[]
32
- setValue: (field: string, value: string) => void
33
- setOp: (field: string, op: FilterOp) => void
34
- clearFilter: (field: string) => void
35
- clearAll: () => void
36
- }
37
-
38
- interface FilterDraftOptions {
39
- available: ComputedRef<ListColumn[]>
40
- filterableColumns: ComputedRef<ListColumn[]>
41
- filter: ComputedRef<Record<string, FilterCell>>
42
- setFilter: (next: Record<string, FilterCell>) => void
43
- }
44
-
45
- export function useListFilterDraft(opts: FilterDraftOptions): ListFilterDraft {
46
- const { t, lang } = useT()
47
- const draft = reactive<Record<string, FilterCell>>({})
48
-
49
- // Overwrite each draft cell FROM the committed `filter` so deep-link / Back-Forward / chip-removal reflect
50
- // back into the open panel; drop drafts for columns this collection no longer has. We key the watch on the
51
- // committed filter's CONTENT (its JSON), not its object identity: `filter` recomputes to a fresh-but-EQUAL
52
- // object on every `route.query` change (page/sort/perPage navigations too), and resyncing on those identity
53
- // flips would wipe an in-progress panel edit. Clobber-safe on a real filter commit: a commit sends ALL
54
- // drafts at once, so post-commit `filter` equals `draft` (a no-op overwrite) — the content only actually
55
- // changes on external navigation (deep-link/Back-Forward/chip-removal), which is when the panel should resync.
56
- watch([opts.filterableColumns, () => JSON.stringify(opts.filter.value)], () => {
57
- const cols = opts.filterableColumns.value
58
- for (const c of cols) {
59
- const cm = opts.filter.value[c.key]
60
- draft[c.key] = { op: cm?.op ?? DEFAULT_OP[c.filterKind ?? 'text'], value: cm?.value ?? '' }
61
- }
62
- for (const key of Object.keys(draft)) if (!cols.some((c) => c.key === key)) Reflect.deleteProperty(draft, key)
63
- }, { immediate: true })
64
-
65
- function opsFor(c: ListColumn): readonly FilterOp[] {
66
- return OPS_BY_KIND[c.filterKind ?? 'text']
67
- }
68
- // Operator labels are kind-aware: a datetime comparison reads "before / on or before / …" instead of the
69
- // generic "less than / less or equal / …".
70
- function opLabel(kind: FilterKind, op: FilterOp): string {
71
- if (kind === 'datetime' && (op === 'lt' || op === 'lte' || op === 'gt' || op === 'gte')) return t(`filter.opDate.${op}`)
72
- return t(`filter.op.${op}`)
73
- }
74
- // Choices for an `enum` / `stringSet` value control: a fixed draft/published set for `status`, else the
75
- // choice field's own (single- and multi-choice fields both carry `options.choices`).
76
- function enumOptions(c: ListColumn): { label: string; value: string }[] {
77
- if (c.key === 'status') return [
78
- { value: 'draft', label: t('pageSettings.statusDraft') },
79
- { value: 'published', label: t('pageSettings.statusPublished') },
80
- ]
81
- // A choice label is `Localized`, so resolve it here as the editor widget does — `{{ o.label }}` would
82
- // stringify a `{ en, de }` map into the filter dropdown.
83
- return ((c.field?.options?.choices ?? []) as { label: Localized; value: string }[])
84
- .map((o) => ({ value: o.value, label: resolveLocalized(o.label, lang.value) ?? o.value }))
85
- }
86
- function displayValue(kind: FilterKind, value: string): string {
87
- if (kind === 'boolean') return value === 'true' ? t('filter.bool.true') : value === 'false' ? t('filter.bool.false') : value
88
- return value
89
- }
90
-
91
- // Builds a fresh clause map from the non-empty draft cells and navigates via `setFilter` (which also
92
- // resets page → 1).
93
- let timer: ReturnType<typeof setTimeout> | undefined
94
- function commitDrafts() {
95
- const next: Record<string, FilterCell> = {}
96
- for (const [field, cell] of Object.entries(draft)) {
97
- if (cell.value !== '' && cell.value != null) next[field] = { op: cell.op, value: cell.value }
98
- }
99
- opts.setFilter(next)
100
- }
101
- function setValue(field: string, value: string) {
102
- if (draft[field]) draft[field].value = value
103
- clearTimeout(timer)
104
- timer = setTimeout(commitDrafts, COMMIT_DEBOUNCE_MS)
105
- }
106
- function setOp(field: string, op: FilterOp) {
107
- if (draft[field]) draft[field].op = op
108
- // An operator change is deliberate — re-query immediately (no debounce) if the field has a value.
109
- clearTimeout(timer)
110
- commitDrafts()
111
- }
112
- function clearFilter(field: string) {
113
- if (draft[field]) draft[field].value = ''
114
- commitDrafts() // re-commits the drafts with this one now empty → the clause is dropped (and page → 1)
115
- }
116
- function clearAll() {
117
- for (const cell of Object.values(draft)) cell.value = ''
118
- opts.setFilter({}) // clear the committed clauses (and page → 1); the draft-resync watch clears the panel
119
- }
120
- onBeforeUnmount(() => clearTimeout(timer))
121
-
122
- const activeFilters = computed(() => Object.entries(opts.filter.value).map(([key, cell]) => {
123
- const col = opts.available.value.find((c) => c.key === key)
124
- const kind = col?.filterKind ?? 'text'
125
- return { key, op: cell.op, opLabel: opLabel(kind, cell.op), label: col ? columnLabel(col, t) : key, display: displayValue(kind, cell.value) }
126
- }))
127
-
128
- return { draft, activeFilters, opsFor, opLabel, enumOptions, setValue, setOp, clearFilter, clearAll }
129
- }
@@ -1,75 +0,0 @@
1
- // The collection list's row fetch: request, out-of-range clamp, and the inline error state.
2
- import { computed, ref, type ComputedRef, type Ref } from 'vue'
3
-
4
- interface ListRowsOptions {
5
- collection: ComputedRef<string>
6
- /** The sanitized query derived from the URL (sort / page / perPage / filter). */
7
- effectiveQuery: ComputedRef<Record<string, unknown>>
8
- locale: () => string | undefined
9
- page: ComputedRef<number>
10
- perPage: ComputedRef<number>
11
- /** Navigate back into range when the current page emptied out; REPLACE, never push. */
12
- clampPage: (page: number) => void
13
- /** Runs after a SUCCESSFUL fetch — the caller drops its page-scoped state here. A failed fetch keeps
14
- * the previously rendered rows, so anything scoped to them stays valid. */
15
- onLoaded?: () => void
16
- }
17
-
18
- interface ListResponse {
19
- data: Record<string, unknown>[]
20
- total: number
21
- page: number
22
- perPage: number
23
- /** Set by `validateOut` — count of rows in `data` replaced with the quarantine shape. */
24
- quarantinedCount?: number
25
- }
26
-
27
- export function useListRows(opts: ListRowsOptions) {
28
- const { t } = useT()
29
- const rows = ref<Record<string, unknown>[]>([])
30
- const total = ref(0)
31
- const quarantinedCount = ref(0)
32
- // Surfaced when a (re)fetch fails — an inline error + Retry, never a silent stale list or a thrown
33
- // error boundary. Distinct from the empty state (which only shows when the fetch SUCCEEDED with no rows).
34
- const error = ref<string | null>(null)
35
- const totalPages = computed(() => Math.max(1, Math.ceil(total.value / opts.perPage.value)))
36
-
37
- // Monotonic request id: a slow earlier response must not overwrite a newer one.
38
- let seq = 0
39
- async function fetchRows() {
40
- const mine = ++seq
41
- const locale = opts.locale()
42
- try {
43
- const res = await $fetch<ListResponse>(`/api/${opts.collection.value}/readMany`, {
44
- query: { ...opts.effectiveQuery.value, ...(locale ? { locale } : {}) },
45
- })
46
- if (mine !== seq) return
47
- rows.value = res.data
48
- total.value = res.total
49
- quarantinedCount.value = res.quarantinedCount ?? 0
50
- error.value = null
51
- // A page that emptied out from under us — e.g. bulk-deleting the last rows of the trailing page —
52
- // would otherwise strand the user on an out-of-range page showing the "create your first" empty
53
- // state. Clamp back to the last page that still has rows (REPLACE, so the dead page isn't left as a
54
- // history entry that Back would bounce off; the query watch refetches).
55
- if (res.data.length === 0 && res.total > 0 && opts.page.value > 1) {
56
- opts.clampPage(Math.max(1, Math.ceil(res.total / opts.perPage.value)))
57
- }
58
- opts.onLoaded?.()
59
- } catch (e) {
60
- if (mine !== seq) return
61
- // Keep any previously-rendered rows, but surface that this fetch failed (and don't show the
62
- // "create your first" empty state — that would imply the collection is empty when it isn't).
63
- error.value = (e as { statusMessage?: string })?.statusMessage ?? t('list.loadError')
64
- }
65
- }
66
-
67
- return { rows, total, quarantinedCount, error, totalPages, fetchRows } as {
68
- rows: Ref<Record<string, unknown>[]>
69
- total: Ref<number>
70
- quarantinedCount: Ref<number>
71
- error: Ref<string | null>
72
- totalPages: ComputedRef<number>
73
- fetchRows: () => Promise<void>
74
- }
75
- }
@@ -1,28 +0,0 @@
1
- // Page-scoped row selection for the collection list.
2
- //
3
- // The Set is valid only for the rows currently on screen — every (re)fetch replaces them, so the caller
4
- // clears it from its fetch handler. A reactive Set (Vue tracks add/delete/has/size) keeps a single-row
5
- // toggle O(1): no full-Set copy and no whole-table re-diff per click, which mattered at the 500-row cap.
6
- import { computed, reactive, type Ref } from 'vue'
7
-
8
- export function useListSelection(rows: Ref<Record<string, unknown>[]>) {
9
- const selected = reactive(new Set<number>())
10
- const pageIds = computed(() => rows.value.map((r) => Number(r.id)))
11
- const allSelected = computed(() => pageIds.value.length > 0 && pageIds.value.every((id) => selected.has(id)))
12
- // Tri-state header checkbox: some rows selected, but not all of them.
13
- const headerIndeterminate = computed(() => pageIds.value.some((id) => selected.has(id)) && !allSelected.value)
14
-
15
- function toggleRow(id: number, on: boolean) {
16
- if (on) selected.add(id)
17
- else selected.delete(id)
18
- }
19
- function toggleAll(on: boolean) {
20
- selected.clear()
21
- if (on) for (const id of pageIds.value) selected.add(id)
22
- }
23
- function clear() {
24
- selected.clear()
25
- }
26
-
27
- return { selected, allSelected, headerIndeterminate, toggleRow, toggleAll, clear }
28
- }
@@ -1,53 +0,0 @@
1
- import type { Ref } from 'vue'
2
- import type { ListColumn } from '../utils/list-columns'
3
- import { toQuery, toggleSort, parseListQuery, type FilterCell, type ListState } from '../utils/list-query'
4
- import { clampPerPage, DEFAULT_PER_PAGE } from '@michaelthielemann/kestrel-core/client'
5
-
6
- /**
7
- * The URL is the SINGLE SOURCE OF TRUTH for a collection list's committed state (sort · page · perPage ·
8
- * filter). This composable derives that state from `route.query` (sanitized via `parseListQuery`, so a
9
- * hostile/partial URL degrades to defaults rather than reaching the server), and turns each mutation into a
10
- * router navigation: `page` uses `push` (Back walks pages), everything else uses `replace` (typing / toggling
11
- * must not spam history). Every non-page action also resets `page → 1`, so a page-only transition is the only
12
- * one that ever differs by page alone — the push/replace split is exact.
13
- *
14
- * The per-page cookie is a DEFAULT, never state: when the URL carries no `perPage`, the initial value is
15
- * seeded from the cookie (clamped); when perPage changes we write the cookie so a chosen density survives
16
- * navigation. The URL always wins.
17
- */
18
- export function useListUrlState(columns: Ref<ListColumn[]>) {
19
- const route = useRoute()
20
- const router = useRouter()
21
- const perPageCookie = useCookie<number>('kestrel-list-per-page', { default: () => DEFAULT_PER_PAGE })
22
-
23
- const parsed = computed(() => parseListQuery(route.query, columns.value))
24
- const sort = computed(() => parsed.value.sort ?? '-createdAt')
25
- const page = computed(() => parsed.value.page ?? 1)
26
- const perPage = computed(() => parsed.value.perPage ?? clampPerPage(perPageCookie.value)) // URL wins, else cookie
27
- const filter = computed(() => parsed.value.filter ?? {})
28
- // The exact `$fetch` query the list endpoint expects — always the SANITIZED computeds, never the raw URL.
29
- const effectiveQuery = computed(() => toQuery({ sort: sort.value, page: page.value, perPage: perPage.value, filter: filter.value }))
30
-
31
- // We replace the WHOLE query on every navigation, so re-merge `locale` from the RAW URL (it lives outside
32
- // the list-state keys and must survive a sort/page/filter change). Reading `route.query.locale` — NOT a
33
- // resolved prop — keeps a locale-LESS URL locale-less after a list mutation: for a translatable collection
34
- // the prop is the PRIMARY locale even when the URL carries no `?locale`, so merging it would inject
35
- // `locale=<primary>`, flip the host page's `${collection}::${locale}` key and needlessly REMOUNT the list
36
- // mid-edit. The locale switcher still navigates with an explicit `?locale`, which is preserved here (and
37
- // correctly re-keys → remounts on a real locale switch).
38
- function go(patch: Partial<ListState>, mode: 'push' | 'replace') {
39
- const next: ListState = { sort: sort.value, page: page.value, perPage: perPage.value, filter: filter.value, ...patch }
40
- const locale = route.query.locale
41
- return router[mode]({ query: { ...toQuery(next), ...(typeof locale === 'string' ? { locale } : {}) } })
42
- }
43
-
44
- const setSort = (f: string) => go({ sort: toggleSort(sort.value, f), page: 1 }, 'replace')
45
- const setPage = (p: number) => go({ page: p }, 'push')
46
- // Corrective clamp (a page that emptied out from under us) — REPLACE, not push, so the now-invalid page
47
- // doesn't stay a reachable history entry that Back bounces the user forward from on every press.
48
- const clampPage = (p: number) => go({ page: p }, 'replace')
49
- const setPerPage = (n: number) => { perPageCookie.value = clampPerPage(n); return go({ perPage: clampPerPage(n), page: 1 }, 'replace') }
50
- const setFilter = (f: Record<string, FilterCell>) => go({ filter: f, page: 1 }, 'replace')
51
-
52
- return { sort, page, perPage, filter, effectiveQuery, setSort, setPage, clampPage, setPerPage, setFilter }
53
- }
@@ -1,15 +0,0 @@
1
- import { ref } from 'vue'
2
-
3
- // A create saves, then the host navigates the editor to the new record's own URL (`/new` → `/<id>`), which
4
- // tears down the editor that issued the save and remounts a fresh one at the real id. The republish it
5
- // kicked off is still in flight, so the ARRIVING editor must POLL the live status (not do a single refresh)
6
- // to catch the "Generating…" → "Live" transition. This module-scoped flag carries that intent across the
7
- // remount: the departing instance sets it on save, the arriving instance reads-and-clears it on mount.
8
- // Client-only ephemeral signal (set in a user save handler, read on mount) — a plain ref is enough; it never
9
- // runs during SSR, so there is no cross-request leakage.
10
- const pending = ref(false)
11
-
12
- /** Shared "the next editor mount should poll the live status" flag — see the note above. */
13
- export function usePendingPublishPoll() {
14
- return pending
15
- }
@@ -1,30 +0,0 @@
1
- import { computed } from 'vue'
2
- import type { Dim } from '../utils/preview-viewport'
3
-
4
- /**
5
- * Persisted page-builder preview viewport: the target width/height as `Dim`s (a fixed px, or `'auto'`
6
- * which fills the pane). Cookie-backed so the choice survives reloads and stays SSR-safe (server + client
7
- * read the same value — no hydration mismatch), the same pattern as `useRailCollapsed`. Defaults to the
8
- * config-driven desktop width with an `'auto'` height (fills the pane, no container scroll).
9
- *
10
- * Each setter reassigns the whole object (rather than mutating a field) so Nuxt's cookie watcher always
11
- * re-serialises. Note: the seeded default only applies when the cookie is absent — if a project later
12
- * changes `preview.desktopWidth`, an existing cookie keeps its `w` until the Desktop preset is clicked. A
13
- * stale cookie from before Auto height (a numeric `h`, plus a now-ignored `fit`) is tolerated: it just
14
- * reads as a fixed custom height until Desktop is re-selected.
15
- */
16
- export interface PreviewViewport {
17
- w: Dim
18
- h: Dim
19
- }
20
-
21
- export function usePreviewViewport(desktopWidth: number) {
22
- const state = useCookie<PreviewViewport>('kestrel-preview-viewport', {
23
- default: () => ({ w: desktopWidth, h: 'auto' }),
24
- })
25
-
26
- const width = computed({ get: () => state.value.w, set: (v: Dim) => { state.value = { ...state.value, w: v } } })
27
- const height = computed({ get: () => state.value.h, set: (v: Dim) => { state.value = { ...state.value, h: v } } })
28
-
29
- return { width, height }
30
- }
@@ -1,107 +0,0 @@
1
- import { ref, toValue } from 'vue'
2
- import type { MaybeRefOrGetter } from 'vue'
3
-
4
- /** The live publish state of a record's static page, as returned by `GET /api/publishStatus`. */
5
- export interface PublishStatusData {
6
- route: string | null
7
- status: 'success' | 'error' | null
8
- error?: string | null
9
- updatedAt?: string | null
10
- /** Which output the last attempt wrote to — `'local'` dir or `'s3'` bucket; null when no row yet. */
11
- target?: 'local' | 's3' | null
12
- /** The record was saved after its page was last published — the live file is an older version of it.
13
- * The normal working state while editing, since a save no longer republishes (ADR-0008). */
14
- pending?: boolean
15
- /** The consumer opted out of the save/publish split (`output.publishOnSave`): a save republishes on its
16
- * own, so the editor offers no Publish button and never reports unpublished changes. */
17
- publishOnSave?: boolean
18
- /** The page has no publish row at all. Distinct from "a publish is in flight": with the split on, a save
19
- * enqueues nothing, so this state only ends when someone presses Publish. */
20
- neverPublished?: boolean
21
- /** Whether the runtime publisher actually produces files in THIS environment (prod + `output.auto`).
22
- * `false` in dev / with static output off → a page can never turn "Live" here, so don't poll for it. */
23
- generates?: boolean
24
- /** The configured output destination (where the NEXT publish would go), independent of any row. */
25
- driver?: 'local' | 's3'
26
- }
27
-
28
- const EMPTY: PublishStatusData = { route: null, status: null }
29
-
30
- /**
31
- * Fetch (and refetch) the LIVE / generated publish state of the record the editor is on, for the right
32
- * dot of the editor Ampel. Explicit `refresh()` (no auto-fetch) so it is testable in isolation and the
33
- * caller controls timing: the editor refreshes on mount and again after each save (a save may (re)publish
34
- * the page). Skips the request entirely when disabled (non-pageLike collection) or the record is unsaved
35
- * (`id === 'new'` has no server row, hence no route yet). A failed fetch degrades to an empty status
36
- * rather than surfacing an error — the ampel is a non-critical signal.
37
- */
38
- export function usePublishStatus(args: {
39
- collection: MaybeRefOrGetter<string>
40
- id: MaybeRefOrGetter<string | number>
41
- locale?: MaybeRefOrGetter<string | undefined>
42
- enabled?: MaybeRefOrGetter<boolean>
43
- }) {
44
- const data = ref<PublishStatusData>({ ...EMPTY })
45
- // Bumped by every `refresh()` / `refreshUntilSettled()` start; an in-flight poll compares the token it
46
- // captured and bails the moment a newer call supersedes it (navigation, another save), so stale loops
47
- // can't keep writing to `data`.
48
- let pollToken = 0
49
-
50
- async function fetchOnce(): Promise<void> {
51
- // Guard every write by the token captured at call time: if a newer refresh/poll supersedes this one
52
- // while its request is in flight, its (possibly stale) response must NOT overwrite the fresher data —
53
- // two overlapping fetches (a rapid re-save superseding a poll) can otherwise resolve out of order.
54
- const token = pollToken
55
- const enabled = args.enabled === undefined ? true : toValue(args.enabled)
56
- const id = toValue(args.id)
57
- if (!enabled || id === 'new' || id == null) {
58
- if (token === pollToken) data.value = { ...EMPTY }
59
- return
60
- }
61
- try {
62
- const res = await $fetch<PublishStatusData>('/api/publishStatus', {
63
- query: { collection: toValue(args.collection), id, locale: toValue(args.locale) },
64
- })
65
- if (token === pollToken) data.value = res
66
- } catch {
67
- if (token === pollToken) data.value = { ...EMPTY }
68
- }
69
- }
70
-
71
- /** One-shot fetch. Also cancels any running poll (this is the fresh source of truth). */
72
- async function refresh(): Promise<void> {
73
- pollToken++
74
- await fetchOnce()
75
- }
76
-
77
- /**
78
- * Poll the live status until it SETTLES, for the right lamp after a save: in prod a save enqueues an
79
- * async (debounced) republish that finishes a moment later, so a single refresh would only ever catch the
80
- * pre-republish row. Stops when the record has no route, the environment doesn't generate (`generates ===
81
- * false` — dev / static output off: nothing will ever be produced, so polling is pointless), or a terminal
82
- * outcome (`success`/`error`) from the NEW republish lands.
83
- *
84
- * `since` is the row's `updatedAt` BEFORE this save. `publish_status` is a latest-state upsert that is NOT
85
- * cleared first, so a re-saved page keeps its prior success/error row until the fresh render rewrites it —
86
- * without this baseline the very first poll would read that stale row and settle instantly (e.g. staying
87
- * green "Live" for a re-save that actually failed to republish). So a terminal outcome only counts as
88
- * settled once its `updatedAt` differs from `since`. Bounded by `attempts`; superseded-safe via `pollToken`.
89
- */
90
- async function refreshUntilSettled(opts?: { attempts?: number; delayMs?: number; since?: string | null }): Promise<void> {
91
- const token = ++pollToken
92
- const attempts = opts?.attempts ?? 12
93
- const delayMs = opts?.delayMs ?? 2000
94
- const since = opts?.since ?? null
95
- for (let i = 0; i < attempts; i++) {
96
- if (token !== pollToken) return
97
- await fetchOnce()
98
- if (token !== pollToken) return
99
- const d = data.value
100
- const settled = (d.status === 'success' || d.status === 'error') && (d.updatedAt ?? null) !== since
101
- if (!d.route || d.generates === false || settled) return
102
- if (i < attempts - 1) await new Promise((resolve) => setTimeout(resolve, delayMs))
103
- }
104
- }
105
-
106
- return { data, refresh, refreshUntilSettled }
107
- }
@@ -1,14 +0,0 @@
1
- /**
2
- * Collapsed/expanded state for the admin navigation rail.
3
- * Backed by a cookie so the choice persists across reloads and stays SSR-safe
4
- * (no hydration mismatch — server and client read the same value).
5
- */
6
- export function useRailCollapsed() {
7
- const collapsed = useCookie<boolean>('kestrel-rail-collapsed', { default: () => false })
8
-
9
- function toggle() {
10
- collapsed.value = !collapsed.value
11
- }
12
-
13
- return { collapsed, toggle }
14
- }
@@ -1,36 +0,0 @@
1
- export type ThemeName = 'light' | 'dark'
2
-
3
- /**
4
- * The initial admin theme: an explicit stored choice wins; otherwise follow the OS
5
- * `prefers-color-scheme`; default to light. Pure so it can be unit-tested without a DOM.
6
- */
7
- export function resolveInitialTheme(opts: { stored?: string | null; prefersDark?: boolean }): ThemeName {
8
- if (opts.stored === 'light' || opts.stored === 'dark') return opts.stored
9
- return opts.prefersDark ? 'dark' : 'light'
10
- }
11
-
12
- /**
13
- * Admin colour theme (light/dark). The explicit choice is cookie-backed so it persists and is
14
- * SSR-safe (server and client read the same value — no hydration mismatch). Until a choice is made
15
- * the theme follows the OS preference, picked up client-side. The admin layout binds `theme` onto
16
- * `<html data-theme>`; `_tokens.scss` keys the palette off that attribute.
17
- */
18
- export function useTheme() {
19
- const stored = useCookie<ThemeName | null>('kestrel-admin-theme', { default: () => null })
20
- const prefersDark = ref(false)
21
- let mq: MediaQueryList | null = null
22
- const onChange = (e: MediaQueryListEvent) => { prefersDark.value = e.matches }
23
-
24
- onMounted(() => {
25
- mq = window.matchMedia('(prefers-color-scheme: dark)')
26
- prefersDark.value = mq.matches
27
- mq.addEventListener('change', onChange)
28
- })
29
- onUnmounted(() => mq?.removeEventListener('change', onChange))
30
-
31
- const theme = computed<ThemeName>(() => resolveInitialTheme({ stored: stored.value, prefersDark: prefersDark.value }))
32
- const setTheme = (t: ThemeName) => { stored.value = t }
33
- const toggle = () => setTheme(theme.value === 'dark' ? 'light' : 'dark')
34
-
35
- return { theme, toggle, setTheme }
36
- }
@@ -1,38 +0,0 @@
1
- // Toolbar disclosure panels (filter / columns). Plain inline panels, not teleported, so they stay
2
- // testable and keep their scoped styles; a document listener closes them on an outside click.
3
- import { onBeforeUnmount, onMounted, ref } from 'vue'
4
-
5
- export function useToolbarPanel<T extends string>() {
6
- const container = ref<HTMLElement | null>(null)
7
- const open = ref<T | null>(null)
8
- // The element that opened the current panel — Escape restores focus to it (otherwise closing the panel
9
- // from inside drops keyboard focus onto <body>, stranding the keyboard user at the top of the document).
10
- let trigger: HTMLElement | null = null
11
-
12
- function toggle(which: T, e: MouseEvent) {
13
- if (open.value === which) { open.value = null; return }
14
- trigger = e.currentTarget as HTMLElement
15
- open.value = which
16
- }
17
- function onDocPointer(e: PointerEvent) {
18
- // Outside click: close but do NOT steal focus — it belongs wherever the user just clicked.
19
- if (container.value && !container.value.contains(e.target as Node)) open.value = null
20
- }
21
- function onDocKeydown(e: KeyboardEvent) {
22
- if (e.key === 'Escape' && open.value) {
23
- open.value = null
24
- trigger?.focus() // return focus to the trigger, per the disclosure/APG pattern
25
- }
26
- }
27
-
28
- onMounted(() => {
29
- document.addEventListener('pointerdown', onDocPointer)
30
- document.addEventListener('keydown', onDocKeydown)
31
- })
32
- onBeforeUnmount(() => {
33
- document.removeEventListener('pointerdown', onDocPointer)
34
- document.removeEventListener('keydown', onDocKeydown)
35
- })
36
-
37
- return { container, open, toggle }
38
- }
@@ -1,22 +0,0 @@
1
- import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
2
-
3
- /**
4
- * Confirm before navigating away from unsaved editor changes. Registers BOTH route guards on purpose:
5
- * `onBeforeRouteLeave` fires when the matched route record is dropped (editor → list, rail nav), but
6
- * Vue Router fires `onBeforeRouteUpdate` instead when a param/query-only change reuses the SAME record —
7
- * which is exactly what the LocaleBar does (edit a sibling translation `/admin/{coll}/{id}`,
8
- * create-and-link `/admin/{coll}/new?…`, switch a singleton's `?locale`). Without the update guard those
9
- * same-record transitions silently discard in-progress edits (the `key: route.fullPath` remount only
10
- * happens AFTER the router has already committed the navigation, so it can't veto it).
11
- *
12
- * `isDirty` / `skip` / `message` are read lazily on each navigation so the guard always sees current
13
- * state. Returning `false` from a guard cancels the navigation; `skip()` lets a post-save / delete
14
- * redirect pass straight through.
15
- */
16
- export function useUnsavedGuard(isDirty: () => boolean, message: () => string, skip: () => boolean = () => false): void {
17
- const guard = () => {
18
- if (!skip() && isDirty() && !confirm(message())) return false
19
- }
20
- onBeforeRouteLeave(guard)
21
- onBeforeRouteUpdate(guard)
22
- }