@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,107 +0,0 @@
1
- <script setup lang="ts">
2
- // Pruvious-style content-locale switcher for the record editor. Shows every configured website locale:
3
- // the active one as a label, an existing sibling as a pen (edit) + copy control, a missing one as a
4
- // "+" (create) link. Content locales only — not the admin-UI language.
5
- const props = defineProps<{
6
- collection: string
7
- id: string
8
- mode: 'single' | 'multi'
9
- current: string
10
- translations: Record<string, number | null>
11
- group?: string
12
- }>()
13
- const emit = defineEmits<{ copyFrom: [locale: string] }>()
14
-
15
- const { locales } = useContentLocales()
16
-
17
- /** Existing sibling row id for a locale (multi only), or null when there is no translation yet. */
18
- function existingId(loc: string): number | null {
19
- const v = props.translations[loc]
20
- return typeof v === 'number' ? v : null
21
- }
22
- function createLink(loc: string): string {
23
- const q = new URLSearchParams({ locale: loc })
24
- if (props.group) q.set('group', props.group)
25
- return `/admin/${props.collection}/new?${q.toString()}`
26
- }
27
- const up = (s: string) => s.toUpperCase()
28
- const { t } = useT()
29
- </script>
30
-
31
- <template>
32
- <div class="locale-bar" role="group" :aria-label="t('localeBar.groupLabel')">
33
- <template v-for="loc in locales" :key="loc">
34
- <span v-if="loc === current" class="locale-bar__item locale-bar__item--active" aria-current="true">{{ up(loc) }}</span>
35
-
36
- <NuxtLink
37
- v-else-if="mode === 'single'"
38
- class="locale-bar__item locale-bar__btn"
39
- :to="`/admin/${collection}?locale=${loc}`"
40
- :aria-label="t('localeBar.editLocale', { loc: up(loc) })"
41
- >{{ up(loc) }}<KestrelUiIcon name="pencil" :size="14" /></NuxtLink>
42
-
43
- <span v-else-if="existingId(loc) !== null" class="locale-bar__item">
44
- <!-- No `?locale=` here: the target id already IS a specific locale's row (useEditForm reads the
45
- active locale off the loaded record, not the query, once an id is known). -->
46
- <NuxtLink class="locale-bar__btn" :to="`/admin/${collection}/${existingId(loc)}`" :aria-label="t('localeBar.editLocale', { loc: up(loc) })">{{ up(loc) }}<KestrelUiIcon name="pencil" :size="14" /></NuxtLink>
47
- <button type="button" class="locale-bar__btn locale-bar__btn--copy" :aria-label="t('localeBar.copyInto', { loc: up(loc), current: up(current) })" @click="emit('copyFrom', loc)"><KestrelUiIcon name="copy" :size="14" /></button>
48
- </span>
49
-
50
- <NuxtLink
51
- v-else
52
- class="locale-bar__item locale-bar__btn locale-bar__btn--add"
53
- :to="createLink(loc)"
54
- :aria-label="t('localeBar.createTranslation', { loc: up(loc) })"
55
- >{{ up(loc) }}<KestrelUiIcon name="plus" :size="14" /></NuxtLink>
56
- </template>
57
- </div>
58
- </template>
59
-
60
- <style lang="scss">
61
- .locale-bar {
62
- display: flex;
63
- align-items: center;
64
- gap: var(--space-2);
65
-
66
- &__item {
67
- display: inline-flex;
68
- align-items: center;
69
- gap: var(--space-1);
70
- font-size: var(--text-sm);
71
- font-weight: var(--weight-medium);
72
-
73
- &--active {
74
- padding: var(--space-1) var(--space-2);
75
- border-radius: var(--radius-sm);
76
- background: var(--color-surface);
77
- border: 1px solid var(--color-border);
78
- color: var(--color-text);
79
- }
80
- }
81
- &__btn {
82
- display: inline-flex;
83
- align-items: center;
84
- gap: var(--space-1);
85
- padding: var(--space-1) var(--space-2);
86
- border: 1px solid var(--color-border);
87
- border-radius: var(--radius-sm);
88
- background: transparent;
89
- color: var(--color-text-muted);
90
- text-decoration: none;
91
- cursor: pointer;
92
-
93
- &:hover {
94
- border-color: var(--color-text-muted);
95
- color: var(--color-text);
96
- }
97
- &--add {
98
- border-style: solid;
99
- border-color: var(--color-border);
100
- color: var(--color-text-muted);
101
- }
102
- &--copy {
103
- padding: var(--space-1);
104
- }
105
- }
106
- }
107
- </style>
@@ -1,129 +0,0 @@
1
- <script setup lang="ts">
2
- import type { FieldDef, LayoutNode, SeoMeta } from '@michaelthielemann/kestrel-core'
3
- import { slugify } from '@michaelthielemann/kestrel-core/client'
4
- // The collection (page) field list — delegates to the shared <KestrelFieldLayout> renderer (rows / groups /
5
- // widths, or one field per row when the collection declares no layout), shared by the flat-collection
6
- // form and the 3-pane editor's root-selected pane so the wiring lives in exactly one place. pageLike
7
- // collections also get their routable `path` (slug); seo-enabled ones get the SEO section — both as
8
- // page-level system fields bracketing the collection fields.
9
- const props = defineProps<{
10
- fields: Record<string, FieldDef>
11
- /** Normalized admin editor layout (from the serialized collection). Absent → one field per row. */
12
- fieldLayout?: LayoutNode[]
13
- values: Record<string, unknown>
14
- errors: Record<string, string>
15
- /** Root field keys holding a stale reference — shown as a non-blocking warning note. */
16
- deadFields?: Set<string>
17
- locale: string
18
- pageLike?: boolean
19
- seo?: boolean
20
- status?: boolean
21
- disabled?: boolean
22
- }>()
23
- const emit = defineEmits<{ update: [name: string, value: unknown] }>()
24
- const { t } = useT()
25
-
26
- // A project with a single layout has nothing to choose, so the control stays out of the pane entirely
27
- // rather than offering one dead option.
28
- const layoutOptions = computed(() => layoutSelectOptions(useOfferableLayouts(), t('pageSettings.layoutDefault')))
29
- const showLayout = computed(() => !!props.pageLike && layoutOptions.value.length > 1)
30
-
31
- // Article metadata is an installation-wide policy (`kestrel.seo.articleMeta`), not a per-collection one,
32
- // so it comes from runtimeConfig rather than the serialized collection schema.
33
- const articleMeta = computed(() => (useRuntimeConfig().public as { seoArticleMeta?: boolean }).seoArticleMeta === true)
34
-
35
- // Live preview of the slug the server will auto-generate from the title while the field is left blank
36
- // (the server slugifies the title on save). Falls back to '/' when there is no title yet.
37
- const slugPlaceholder = computed(() => {
38
- const s = slugify(recordTitle(props.fields, props.values))
39
- return s ? `/${s}` : '/'
40
- })
41
- </script>
42
-
43
- <template>
44
- <!-- Publish gate (the `status` system column). The primary page-level control, so it leads the pane:
45
- Draft keeps the page out of the generated site; Published renders + auto-publishes it. -->
46
- <KestrelUiField
47
- v-if="status"
48
- class="page-settings__status"
49
- :label="t('pageSettings.statusLabel')"
50
- :hint="t('pageSettings.statusHint')"
51
- >
52
- <template #default="f">
53
- <KestrelUiSelect
54
- :model-value="(values.status as string) ?? 'draft'"
55
- :options="[
56
- { label: t('pageSettings.statusDraft'), value: 'draft' },
57
- { label: t('pageSettings.statusPublished'), value: 'published' },
58
- ]"
59
- :disabled="disabled"
60
- v-bind="f"
61
- @update:model-value="(v) => emit('update', 'status', v)"
62
- />
63
- </template>
64
- </KestrelUiField>
65
-
66
- <!-- The collection (page) fields, laid out by the shared renderer (rows / groups / widths, or one field
67
- per row when the collection declares no `fieldLayout`). The condition gate + dead-ref note live inside. -->
68
- <KestrelFieldLayout
69
- :layout="fieldLayout"
70
- :fields="fields"
71
- :values="values"
72
- :errors="errors"
73
- :dead-fields="deadFields"
74
- :locale="locale"
75
- :disabled="disabled"
76
- @update="(name, value) => emit('update', name, value)"
77
- />
78
-
79
- <!-- Page slug (the routable path). A system column on pageLike collections, edited like a field. -->
80
- <KestrelUiField
81
- v-if="pageLike"
82
- class="page-settings__slug"
83
- :label="t('pageSettings.slugLabel')"
84
- :hint="t('pageSettings.slugHint')"
85
- :error="errors.path || null"
86
- >
87
- <template #default="f">
88
- <KestrelUiTextInput
89
- :model-value="(values.path as string) ?? ''"
90
- :disabled="disabled"
91
- :placeholder="slugPlaceholder"
92
- v-bind="f"
93
- @update:model-value="(v) => emit('update', 'path', v)"
94
- />
95
- </template>
96
- </KestrelUiField>
97
-
98
- <!-- Which layout renders this page (the `layout` system column). Empty = the `default` layout, so an
99
- unset value keeps rendering exactly as a project without the column. -->
100
- <KestrelUiField
101
- v-if="showLayout"
102
- class="page-settings__layout"
103
- :label="t('pageSettings.layoutLabel')"
104
- :hint="t('pageSettings.layoutHint')"
105
- :error="errors.layout || null"
106
- >
107
- <template #default="f">
108
- <KestrelUiSelect
109
- :model-value="(values.layout as string) ?? ''"
110
- :options="layoutOptions"
111
- :disabled="disabled"
112
- v-bind="f"
113
- @update:model-value="(v) => emit('update', 'layout', v)"
114
- />
115
- </template>
116
- </KestrelUiField>
117
-
118
- <!-- Page SEO (meta title/description/noindex + Google preview). The `seo` JSON system column. -->
119
- <KestrelSeoFields
120
- v-if="seo"
121
- :value="(values.seo as SeoMeta) ?? {}"
122
- :page-title="(values.title as string) ?? ''"
123
- :path="(values.path as string) ?? ''"
124
- :locale="locale"
125
- :disabled="disabled"
126
- :article-meta="articleMeta"
127
- @update="(v) => emit('update', 'seo', v)"
128
- />
129
- </template>
@@ -1,39 +0,0 @@
1
- <script setup lang="ts">
2
- import type { PageFieldsBindings } from '../utils/editor-form-context'
3
-
4
- // The page-fields fragment shared by both editor bodies: an optional content-locale switcher
5
- // (labelled like a page field) sitting above the collection (page) field list. Centralises the
6
- // LocaleBar + PageFields wiring so the flat form and the 3-pane editor's root pane stay in sync.
7
- // Props are the shared `PageFieldsBindings` type — the same shape the shell pre-builds and provides.
8
- defineProps<PageFieldsBindings>()
9
- const emit = defineEmits<{ copyFrom: [locale: string]; update: [name: string, value: unknown] }>()
10
- const { t } = useT()
11
- </script>
12
-
13
- <template>
14
- <div v-if="translatable" class="ui-field">
15
- <span class="ui-field__label">{{ t('localeBar.fieldLabel') }}</span>
16
- <KestrelLocaleBar
17
- :collection="collection"
18
- :id="id"
19
- :mode="mode"
20
- :current="locale"
21
- :translations="translations"
22
- :group="group"
23
- @copy-from="(loc) => emit('copyFrom', loc)"
24
- />
25
- </div>
26
- <KestrelPageFields
27
- :fields="fields"
28
- :field-layout="fieldLayout"
29
- :values="values"
30
- :errors="errors"
31
- :dead-fields="deadFields"
32
- :locale="locale"
33
- :page-like="pageLike"
34
- :seo="seo"
35
- :status="status"
36
- :disabled="disabled"
37
- @update="(name, value) => emit('update', name, value)"
38
- />
39
- </template>
@@ -1,210 +0,0 @@
1
- <script setup lang="ts">
2
- import { computed } from 'vue'
3
- import { localePath } from '@michaelthielemann/kestrel-core/client'
4
- import type { FieldDef, SeoMeta } from '@michaelthielemann/kestrel-core'
5
- // The page-level SEO section: edit the meta title/description/noindex (the `seo` system column) with a
6
- // live Google-result preview. Emits the whole merged SeoMeta so the parent routes it through setField.
7
- const props = defineProps<{
8
- value: SeoMeta
9
- pageTitle?: string
10
- path?: string
11
- locale: string
12
- disabled?: boolean
13
- /** `kestrel.seo.articleMeta`. Off (the default) hides author/date/keywords entirely: an installation
14
- * that must not attribute content should never be offered the fields, not merely stop publishing
15
- * them. Stored values are hidden, never cleared — turning the flag back on restores them. */
16
- articleMeta?: boolean
17
- }>()
18
- const emit = defineEmits<{ update: [seo: SeoMeta] }>()
19
- const { t } = useT()
20
- const { primary, prefixPrimary } = useContentLocales()
21
-
22
- // Soft guidance limits (Google truncates ~ these lengths).
23
- const TITLE_MAX = 60
24
- const DESC_MAX = 160
25
-
26
- function patch(next: Partial<SeoMeta>) {
27
- emit('update', { ...props.value, ...next })
28
- }
29
-
30
- const previewTitle = computed(() => props.value.title?.trim() || props.pageTitle?.trim() || t('seo.untitled'))
31
- const previewDesc = computed(() => props.value.description?.trim() || t('seo.noDescription'))
32
-
33
- const host = computed(() => {
34
- try { return useRequestURL().host } catch { return '' }
35
- })
36
- const previewUrl = computed(() => `${host.value}${localePath(props.path || '/', props.locale, primary, prefixPrimary)}`)
37
-
38
- const titleLen = computed(() => (props.value.title ?? '').length)
39
- const descLen = computed(() => (props.value.description ?? '').length)
40
-
41
- // The social-share image (og:image) rides the standard media widget over a synthetic single-media field
42
- // def — same picker/thumb/remove UX as any media field, stored as `seo.image` (a media id).
43
- const socialImageField = { type: 'media', options: { accept: 'image' } } as FieldDef
44
- </script>
45
-
46
- <template>
47
- <div class="seo-fields">
48
- <p class="seo-fields__section-label">{{ t('seo.sectionLabel') }}</p>
49
-
50
- <!-- Google SERP-style preview. Decorative (it mirrors the inputs below) → hidden from SRs. -->
51
- <div class="seo-preview" aria-hidden="true">
52
- <div class="seo-preview__url">{{ previewUrl }}</div>
53
- <div class="seo-preview__title">{{ previewTitle }}</div>
54
- <div class="seo-preview__desc">{{ previewDesc }}</div>
55
- </div>
56
-
57
- <KestrelUiField class="seo-fields__title" :label="t('seo.metaTitle')" :hint="`${titleLen}/${TITLE_MAX}`">
58
- <template #default="f">
59
- <KestrelUiTextInput
60
- :model-value="value.title ?? ''"
61
- :placeholder="pageTitle"
62
- :disabled="disabled"
63
- v-bind="f"
64
- @update:model-value="(v) => patch({ title: v ?? '' })"
65
- />
66
- </template>
67
- </KestrelUiField>
68
-
69
- <KestrelUiField class="seo-fields__desc" :label="t('seo.metaDescription')" :hint="`${descLen}/${DESC_MAX}`">
70
- <template #default="f">
71
- <KestrelUiTextarea
72
- :model-value="value.description ?? ''"
73
- :rows="3"
74
- :disabled="disabled"
75
- v-bind="f"
76
- @update:model-value="(v) => patch({ description: v ?? '' })"
77
- />
78
- </template>
79
- </KestrelUiField>
80
-
81
- <KestrelUiField class="seo-fields__image" :label="t('seo.socialImage')" :hint="t('seo.socialImageHint')">
82
- <template #default="f">
83
- <KestrelFieldMedia
84
- :field="socialImageField"
85
- name="seoImage"
86
- :locale="locale"
87
- :disabled="disabled"
88
- v-bind="f"
89
- :model-value="value.image ?? null"
90
- @update:model-value="(v) => patch({ image: typeof v === 'number' ? v : null })"
91
- />
92
- </template>
93
- </KestrelUiField>
94
-
95
- <!-- Article metadata (schema.org author / datePublished / keywords). Opt-in per installation. -->
96
- <template v-if="articleMeta">
97
- <KestrelUiField class="seo-fields__author" :label="t('seo.author')" :hint="t('seo.authorHint')">
98
- <template #default="f">
99
- <KestrelUiTextInput
100
- :model-value="value.author ?? ''"
101
- :disabled="disabled"
102
- v-bind="f"
103
- @update:model-value="(v) => patch({ author: v ?? '' })"
104
- />
105
- </template>
106
- </KestrelUiField>
107
-
108
- <KestrelUiField class="seo-fields__published" :label="t('seo.publishedDate')" :hint="t('seo.publishedDateHint')">
109
- <template #default="f">
110
- <KestrelUiDatePicker
111
- :model-value="value.publishedDate || null"
112
- precision="date"
113
- :disabled="disabled"
114
- :aria-label="t('seo.publishedDate')"
115
- :describedby="f['aria-describedby']"
116
- @update:model-value="(v) => patch({ publishedDate: v ?? '' })"
117
- />
118
- </template>
119
- </KestrelUiField>
120
-
121
- <KestrelUiField class="seo-fields__keywords" :label="t('seo.keywords')" :hint="t('seo.keywordsHint')">
122
- <template #default="f">
123
- <KestrelUiTextInput
124
- :model-value="value.keywords ?? ''"
125
- :disabled="disabled"
126
- v-bind="f"
127
- @update:model-value="(v) => patch({ keywords: v ?? '' })"
128
- />
129
- </template>
130
- </KestrelUiField>
131
- </template>
132
-
133
- <!-- eslint-disable-next-line vuejs-accessibility/label-has-for -- native wrapping label around a custom UiCheckbox; no `for`/`id` pair needed, invisible to static analysis -->
134
- <label class="seo-fields__noindex">
135
- <KestrelUiCheckbox
136
- :model-value="!!value.noindex"
137
- :disabled="disabled"
138
- @update:model-value="(v) => patch({ noindex: v })"
139
- />
140
- <span>{{ t('seo.noindexLabel') }}</span>
141
- </label>
142
- </div>
143
- </template>
144
-
145
- <style lang="scss">
146
- .seo-fields {
147
- display: flex;
148
- flex-direction: column;
149
- gap: var(--space-4);
150
-
151
- &__section-label {
152
- margin: 0;
153
- font-size: var(--text-sm);
154
- font-weight: var(--weight-medium);
155
- color: var(--color-text-muted);
156
- }
157
-
158
- &__noindex {
159
- display: inline-flex;
160
- align-items: center;
161
- gap: var(--space-2);
162
- font-size: var(--text-sm);
163
- cursor: pointer;
164
-
165
- input {
166
- width: 1rem;
167
- height: 1rem;
168
- accent-color: var(--color-primary);
169
- }
170
- }
171
- }
172
-
173
- // A restrained take on a Google result card — enough to gauge truncation, not a pixel-perfect clone.
174
- .seo-preview {
175
- padding: var(--space-3);
176
- border: 1px solid var(--color-border);
177
- border-radius: var(--radius-md);
178
- background: var(--color-surface);
179
-
180
- &__url {
181
- font-size: var(--text-xs, 0.75rem);
182
- color: var(--color-text-muted);
183
- white-space: nowrap;
184
- overflow: hidden;
185
- text-overflow: ellipsis;
186
- }
187
- &__title {
188
- margin-top: var(--space-1);
189
- font-size: var(--text-lg);
190
- line-height: 1.3;
191
- color: var(--color-link, #1a0dab);
192
- overflow: hidden;
193
- text-overflow: ellipsis;
194
- display: -webkit-box;
195
- -webkit-line-clamp: 1;
196
- line-clamp: 1;
197
- -webkit-box-orient: vertical;
198
- }
199
- &__desc {
200
- margin-top: var(--space-1);
201
- font-size: var(--text-sm);
202
- color: var(--color-text);
203
- overflow: hidden;
204
- display: -webkit-box;
205
- -webkit-line-clamp: 2;
206
- line-clamp: 2;
207
- -webkit-box-orient: vertical;
208
- }
209
- }
210
- </style>
@@ -1,93 +0,0 @@
1
- <script setup lang="ts">
2
- // A proper editor for a single-record (singleton) collection — the same header-driven chrome as the
3
- // record editor, minus list/new/delete. The header carries the title and a Save button that submits the
4
- // CollectionEditor's form by id; `saving`/`dirty` come from the editor's exposed state.
5
- import type { EditorExpose } from '../utils/editor-expose'
6
-
7
- defineProps<{ collection: string; title: string; localeParam?: string }>()
8
-
9
- const { t } = useT()
10
- const toast = useToast()
11
-
12
- const EDITOR_FORM_ID = 'singleton-editor'
13
- const editorRef = ref<EditorExpose | null>(null)
14
- const saving = computed(() => editorRef.value?.saving ?? false)
15
-
16
- function onSaved() {
17
- toast.success(t('toast.saved'))
18
- }
19
-
20
- // Unsaved edits preview through a ticket instead of a save; the editor owns both paths (ADR-0008).
21
- const previewTitle = computed(() => t(editorRef.value?.dirty ? 'editor.previewUnsaved' : 'editor.openInNewTab'))
22
-
23
- // Guard in-app navigation away from unsaved changes — both record-change AND same-record `?locale`
24
- // switches (the LocaleBar's singleton locale switch reuses this route record → onBeforeRouteUpdate).
25
- // The editor itself guards native unload.
26
- useUnsavedGuard(() => editorRef.value?.dirty ?? false, () => t('editor.discardConfirm'))
27
- </script>
28
-
29
- <template>
30
- <section class="singleton">
31
- <div class="singleton__head">
32
- <h1 class="singleton__title">{{ title }}</h1>
33
- <div class="singleton__actions">
34
- <KestrelUiButton type="button" variant="ghost" size="sm" icon="undo" :disabled="saving || !editorRef?.canUndo" :title="t('history.undo')" :aria-label="t('history.undo')" @click="editorRef?.undo()" />
35
- <KestrelUiButton type="button" variant="ghost" size="sm" icon="redo" :disabled="saving || !editorRef?.canRedo" :title="t('history.redo')" :aria-label="t('history.redo')" @click="editorRef?.redo()" />
36
- <KestrelUiButton type="button" variant="ghost" size="sm" icon="external-link" :title="previewTitle" :aria-label="previewTitle" :loading="editorRef?.previewOpening" @click="editorRef?.openPreview()" />
37
- <!-- A quarantined record is read-only: no Save, no Publish — there is nothing valid to write. -->
38
- <KestrelUiButton v-if="!editorRef?.quarantined" type="submit" :form="EDITOR_FORM_ID" variant="primary" size="sm" icon="check" :loading="saving">{{ t('common.save') }}</KestrelUiButton>
39
- <!-- Publishing is its own decision: Save persists, Publish writes the static page (ADR-0008). -->
40
- <KestrelUiButton v-if="editorRef?.canPublish !== false && !editorRef?.quarantined" type="button" variant="secondary" size="sm" icon="upload" :loading="editorRef?.publishing" :disabled="saving" @click="editorRef?.publish()">{{ t('common.publish') }}</KestrelUiButton>
41
- <KestrelEditorStatus class="singleton__ampel" :dirty="editorRef?.dirty ?? false" :saving="saving" :has-status="editorRef?.hasStatus ?? false" :status="editorRef?.status" :saved-status="editorRef?.savedStatus" :page-like="editorRef?.pageLike ?? false" :live="editorRef?.live" />
42
- </div>
43
- </div>
44
- <KestrelCollectionEditor
45
- ref="editorRef"
46
- :collection="collection"
47
- id="single"
48
- :form-id="EDITOR_FORM_ID"
49
- :locale-param="localeParam"
50
- :actions="false"
51
- @saved="onSaved"
52
- />
53
- </section>
54
- </template>
55
-
56
- <style lang="scss">
57
- // The singleton shell stays fixed and fills the height handed down; the editor body (.editor__flat /
58
- // .editor3) owns the scroll — same model as the record editor (see the admin layout).
59
- .singleton {
60
- display: flex;
61
- flex-direction: column;
62
- gap: var(--space-4);
63
- flex: 1 1 auto;
64
- min-height: 0;
65
- overflow: hidden;
66
-
67
- // Fixed header; the fields scroll below it, so it needs no sticky positioning (matching .record__head).
68
- &__head {
69
- flex: 0 0 auto;
70
- display: flex;
71
- align-items: center;
72
- justify-content: space-between;
73
- gap: var(--space-4);
74
- padding-bottom: var(--space-3);
75
- border-bottom: 1px solid var(--color-border);
76
- }
77
- &__title {
78
- font-size: var(--text-xl);
79
- font-weight: var(--weight-bold);
80
- text-transform: capitalize;
81
- }
82
- &__actions {
83
- display: flex;
84
- align-items: center;
85
- gap: var(--space-2);
86
- }
87
- &__ampel {
88
- margin-inline-start: var(--space-1);
89
- padding-inline-start: var(--space-3);
90
- border-inline-start: 1px solid var(--color-border);
91
- }
92
- }
93
- </style>
@@ -1,68 +0,0 @@
1
- interface AuthState {
2
- authenticated: boolean
3
- exp: number | null
4
- checked: boolean
5
- }
6
-
7
- export function useAuth() {
8
- const state = useState<AuthState>('kestrel-auth', () => ({
9
- authenticated: false,
10
- exp: null,
11
- checked: false,
12
- }))
13
-
14
- const expired = () => state.value.exp !== null && state.value.exp <= Date.now()
15
-
16
- async function checkSession() {
17
- // Fail closed: a transient session-endpoint failure (5xx, offline) must read as
18
- // "not authenticated" so the route guard redirects to login instead of throwing an error page.
19
- try {
20
- const r = await $fetch<{ authenticated: boolean; exp?: number }>('/api/session')
21
- state.value = { authenticated: r.authenticated, exp: r.exp ?? null, checked: true }
22
- } catch {
23
- state.value = { authenticated: false, exp: null, checked: true }
24
- }
25
- return state.value.authenticated
26
- }
27
-
28
- async function ensureSession() {
29
- if (!state.value.checked || expired()) await checkSession()
30
- return state.value.authenticated && !expired()
31
- }
32
-
33
- async function login(password: string) {
34
- const r = await $fetch<{ ok: true; exp: number }>('/api/login', {
35
- method: 'POST',
36
- body: { password },
37
- })
38
- state.value = { authenticated: true, exp: r.exp, checked: true }
39
- return r
40
- }
41
-
42
- /** Drop the local session without a server round-trip — used by the re-auth interceptor on a 401. */
43
- function reset() {
44
- state.value = { authenticated: false, exp: null, checked: true }
45
- }
46
-
47
- async function logout() {
48
- // The desired end state is "logged out" regardless of the response: an expired/rotated
49
- // session makes the guarded logout endpoint 401, but the local session is still cleared.
50
- try {
51
- await $fetch('/api/logout', { method: 'POST' })
52
- } catch {
53
- // already invalid server-side — nothing to clear there
54
- }
55
- state.value = { authenticated: false, exp: null, checked: true }
56
- await navigateTo('/admin/login')
57
- }
58
-
59
- return {
60
- authenticated: computed(() => state.value.authenticated && !expired()),
61
- exp: computed(() => state.value.exp),
62
- checkSession,
63
- ensureSession,
64
- login,
65
- logout,
66
- reset,
67
- }
68
- }