@michaelthielemann/kestrel 4.1.3 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (340) hide show
  1. package/README.md +26 -201
  2. package/dist/boot.d.ts +45 -0
  3. package/dist/boot.js +274 -0
  4. package/dist/cast.d.ts +3 -0
  5. package/dist/cast.js +6 -0
  6. package/dist/catalogue.d.ts +11 -0
  7. package/dist/catalogue.js +1 -0
  8. package/dist/cli.d.ts +2 -0
  9. package/dist/cli.js +55 -0
  10. package/dist/context.d.ts +62 -0
  11. package/dist/context.js +61 -0
  12. package/dist/dataflow.d.ts +3 -0
  13. package/dist/dataflow.js +65 -0
  14. package/dist/defineConfig.d.ts +157 -0
  15. package/dist/defineConfig.js +41 -0
  16. package/dist/defineContract.d.ts +12 -0
  17. package/dist/defineContract.js +16 -0
  18. package/dist/defineModule.d.ts +75 -0
  19. package/dist/defineModule.js +9 -0
  20. package/dist/definePipeline.d.ts +7 -0
  21. package/dist/definePipeline.js +11 -0
  22. package/dist/errors.d.ts +26 -0
  23. package/dist/errors.js +48 -0
  24. package/dist/index.d.ts +19 -0
  25. package/dist/index.js +14 -0
  26. package/dist/load.d.ts +7 -0
  27. package/dist/load.js +79 -0
  28. package/dist/logger.d.ts +18 -0
  29. package/dist/logger.js +36 -0
  30. package/dist/registry.d.ts +14 -0
  31. package/dist/registry.js +87 -0
  32. package/dist/result.d.ts +19 -0
  33. package/dist/result.js +19 -0
  34. package/dist/runner.d.ts +29 -0
  35. package/dist/runner.js +133 -0
  36. package/dist/schema.d.ts +7 -0
  37. package/dist/schema.js +142 -0
  38. package/dist/sort.d.ts +2 -0
  39. package/dist/sort.js +50 -0
  40. package/dist/testing/runPipeline.d.ts +9 -0
  41. package/dist/testing/runPipeline.js +22 -0
  42. package/dist/triggers/cron.d.ts +10 -0
  43. package/dist/triggers/cron.js +44 -0
  44. package/dist/triggers/cronMatch.d.ts +10 -0
  45. package/dist/triggers/cronMatch.js +68 -0
  46. package/dist/triggers/http.d.ts +58 -0
  47. package/dist/triggers/http.js +292 -0
  48. package/package.json +25 -151
  49. package/NOTICE +0 -12
  50. package/kestrel.config.ts +0 -28
  51. package/layers/access/nuxt.config.ts +0 -5
  52. package/layers/access/server/middleware/00.ip-allowlist.ts +0 -52
  53. package/layers/access/server/middleware/access-guard.ts +0 -32
  54. package/layers/access/server/types.d.ts +0 -7
  55. package/layers/admin/app/components/AdminAccount.vue +0 -212
  56. package/layers/admin/app/components/AdminNav.vue +0 -44
  57. package/layers/admin/app/components/BlockFields.vue +0 -67
  58. package/layers/admin/app/components/BlockPreview.vue +0 -413
  59. package/layers/admin/app/components/BlockTree.vue +0 -433
  60. package/layers/admin/app/components/BlocksBody.vue +0 -163
  61. package/layers/admin/app/components/CollectionDeleteDialog.vue +0 -38
  62. package/layers/admin/app/components/CollectionEditor.vue +0 -324
  63. package/layers/admin/app/components/CollectionList.vue +0 -429
  64. package/layers/admin/app/components/CollectionListBulkBar.vue +0 -62
  65. package/layers/admin/app/components/CollectionListColumnsPanel.vue +0 -37
  66. package/layers/admin/app/components/CollectionListFilterPanel.vue +0 -145
  67. package/layers/admin/app/components/CollectionListPager.vue +0 -70
  68. package/layers/admin/app/components/CollectionListTable.vue +0 -347
  69. package/layers/admin/app/components/EditorStatus.vue +0 -211
  70. package/layers/admin/app/components/EditorUnsupported.vue +0 -28
  71. package/layers/admin/app/components/FieldsBody.vue +0 -23
  72. package/layers/admin/app/components/LocaleBar.vue +0 -107
  73. package/layers/admin/app/components/PageFields.vue +0 -129
  74. package/layers/admin/app/components/PageFieldsPane.vue +0 -39
  75. package/layers/admin/app/components/SeoFields.vue +0 -210
  76. package/layers/admin/app/components/SingletonEditor.vue +0 -93
  77. package/layers/admin/app/composables/useAuth.ts +0 -68
  78. package/layers/admin/app/composables/useBlockTree.ts +0 -95
  79. package/layers/admin/app/composables/useBlocks.ts +0 -7
  80. package/layers/admin/app/composables/useCachedResource.ts +0 -35
  81. package/layers/admin/app/composables/useCollectionOps.ts +0 -102
  82. package/layers/admin/app/composables/useCollections.ts +0 -6
  83. package/layers/admin/app/composables/useContentLocales.ts +0 -23
  84. package/layers/admin/app/composables/useEditForm.ts +0 -402
  85. package/layers/admin/app/composables/useLinkResolver.ts +0 -40
  86. package/layers/admin/app/composables/useListBatchActions.ts +0 -78
  87. package/layers/admin/app/composables/useListColumns.ts +0 -53
  88. package/layers/admin/app/composables/useListFilterDraft.ts +0 -129
  89. package/layers/admin/app/composables/useListRows.ts +0 -75
  90. package/layers/admin/app/composables/useListSelection.ts +0 -28
  91. package/layers/admin/app/composables/useListUrlState.ts +0 -53
  92. package/layers/admin/app/composables/usePendingPublishPoll.ts +0 -15
  93. package/layers/admin/app/composables/usePreviewViewport.ts +0 -30
  94. package/layers/admin/app/composables/usePublishStatus.ts +0 -107
  95. package/layers/admin/app/composables/useRailCollapsed.ts +0 -14
  96. package/layers/admin/app/composables/useTheme.ts +0 -36
  97. package/layers/admin/app/composables/useToolbarPanel.ts +0 -38
  98. package/layers/admin/app/composables/useUnsavedGuard.ts +0 -22
  99. package/layers/admin/app/layouts/admin.vue +0 -266
  100. package/layers/admin/app/middleware/admin-auth.ts +0 -4
  101. package/layers/admin/app/pages/admin/[collection]/[id].vue +0 -203
  102. package/layers/admin/app/pages/admin/[collection]/index.vue +0 -62
  103. package/layers/admin/app/pages/admin/index.vue +0 -118
  104. package/layers/admin/app/pages/admin/login.vue +0 -68
  105. package/layers/admin/app/pages/admin/media.vue +0 -28
  106. package/layers/admin/app/pages/admin/references.vue +0 -146
  107. package/layers/admin/app/plugins/admin-style-guard.client.ts +0 -76
  108. package/layers/admin/app/plugins/reauth.client.ts +0 -20
  109. package/layers/admin/app/utils/admin-style-guard.ts +0 -20
  110. package/layers/admin/app/utils/block-tree.ts +0 -214
  111. package/layers/admin/app/utils/collection-ops.ts +0 -69
  112. package/layers/admin/app/utils/dead-refs.ts +0 -30
  113. package/layers/admin/app/utils/edit-form.ts +0 -267
  114. package/layers/admin/app/utils/editor-expose.ts +0 -36
  115. package/layers/admin/app/utils/editor-form-context.ts +0 -82
  116. package/layers/admin/app/utils/editor-registry.ts +0 -20
  117. package/layers/admin/app/utils/field-keys.ts +0 -11
  118. package/layers/admin/app/utils/list-cell.ts +0 -46
  119. package/layers/admin/app/utils/list-columns.ts +0 -142
  120. package/layers/admin/app/utils/list-query.ts +0 -96
  121. package/layers/admin/app/utils/nav-active.ts +0 -9
  122. package/layers/admin/app/utils/populate-blocks.ts +0 -288
  123. package/layers/admin/app/utils/preview-channel.ts +0 -62
  124. package/layers/admin/app/utils/preview-viewport.ts +0 -80
  125. package/layers/admin/app/utils/reauth.ts +0 -67
  126. package/layers/admin/app/utils/record-title.ts +0 -15
  127. package/layers/admin/app/utils/register-builtin-editors.ts +0 -9
  128. package/layers/admin/app/utils/safe-redirect.ts +0 -12
  129. package/layers/admin/nuxt.config.ts +0 -20
  130. package/layers/auth/nuxt.config.ts +0 -1
  131. package/layers/auth/server/plugins/01.register-auth-pipelines.ts +0 -7
  132. package/layers/collections/nuxt.config.ts +0 -1
  133. package/layers/collections/server/plugins/01.register.ts +0 -29
  134. package/layers/collections/server/plugins/02.register-relation-populate.ts +0 -18
  135. package/layers/core/app/composables/layouts.ts +0 -5
  136. package/layers/core/app/utils/field-factories.ts +0 -129
  137. package/layers/core/app/utils/layouts.ts +0 -38
  138. package/layers/core/modules/auto-discovery/extract-block.ts +0 -179
  139. package/layers/core/modules/auto-discovery/index.ts +0 -112
  140. package/layers/core/modules/auto-discovery/package-registry.ts +0 -19
  141. package/layers/core/modules/auto-discovery/scan.ts +0 -170
  142. package/layers/core/modules/auto-discovery/virtual.d.ts +0 -20
  143. package/layers/core/modules/component-namespace/index.ts +0 -44
  144. package/layers/core/modules/component-namespace/shared.ts +0 -24
  145. package/layers/core/modules/kestrel/app-shell.ts +0 -55
  146. package/layers/core/modules/kestrel/auto-imports.ts +0 -44
  147. package/layers/core/modules/kestrel/index.ts +0 -144
  148. package/layers/core/modules/plugin-order/index.ts +0 -69
  149. package/layers/core/modules/plugin-order/plugin-order.ts +0 -273
  150. package/layers/core/nuxt.config.ts +0 -10
  151. package/layers/core/server/api/[...path].ts +0 -98
  152. package/layers/core/server/plugins/00.config.ts +0 -12
  153. package/layers/core/server/plugins/00.migrate.ts +0 -12
  154. package/layers/core/server/plugins/01.register-introspection-pipeline.ts +0 -5
  155. package/layers/core/server/plugins/01.register-openapi-pipeline.ts +0 -5
  156. package/layers/core/server/plugins/01.register-tooling-pipelines.ts +0 -5
  157. package/layers/core/server/plugins/02.schema-sync.ts +0 -58
  158. package/layers/core/server/plugins/04.outbox-worker.ts +0 -31
  159. package/layers/core/server/plugins/05.reindex-refs.ts +0 -5
  160. package/layers/core/server/routes/__kestrel/dashboard.get.ts +0 -29
  161. package/layers/core/server/tasks/db/migrate-module.ts +0 -83
  162. package/layers/core/server/tasks/db/migrate-revisions.ts +0 -20
  163. package/layers/core/server/tasks/db/migrate.ts +0 -50
  164. package/layers/core/server/utils/server-config.ts +0 -81
  165. package/layers/fields/nuxt.config.ts +0 -1
  166. package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +0 -7
  167. package/layers/fields/server/plugins/01.register-field-populate.ts +0 -11
  168. package/layers/media/app/components/KestrelImg.vue +0 -94
  169. package/layers/media/app/components/MediaConflictDialog.vue +0 -35
  170. package/layers/media/app/components/MediaDeleteDialog.vue +0 -52
  171. package/layers/media/app/components/MediaGrid.vue +0 -113
  172. package/layers/media/app/components/MediaImage.vue +0 -70
  173. package/layers/media/app/components/MediaLibrary.vue +0 -456
  174. package/layers/media/app/components/MediaNewFolderDialog.vue +0 -32
  175. package/layers/media/app/components/MediaPathBar.vue +0 -163
  176. package/layers/media/app/components/MediaPicker.vue +0 -19
  177. package/layers/media/app/components/MediaRenameDialog.vue +0 -32
  178. package/layers/media/app/components/MediaTable.vue +0 -75
  179. package/layers/media/app/components/MediaThumb.vue +0 -25
  180. package/layers/media/app/components/MediaToolbar.vue +0 -90
  181. package/layers/media/app/components/MediaUploadDialog.vue +0 -87
  182. package/layers/media/app/components/MediaViewer.vue +0 -137
  183. package/layers/media/app/components/field/Media.vue +0 -263
  184. package/layers/media/app/composables/useBlurUp.ts +0 -55
  185. package/layers/media/app/composables/useMediaClipboard.ts +0 -14
  186. package/layers/media/app/composables/useMediaContextMenu.ts +0 -56
  187. package/layers/media/app/composables/useMediaDnd.ts +0 -56
  188. package/layers/media/app/composables/useMediaLibrary.ts +0 -152
  189. package/layers/media/app/composables/useMediaOps.ts +0 -50
  190. package/layers/media/app/composables/useMediaRelocate.ts +0 -39
  191. package/layers/media/app/composables/useMediaResolver.ts +0 -50
  192. package/layers/media/app/composables/useMediaUpload.ts +0 -93
  193. package/layers/media/app/composables/useMediaVariant.ts +0 -34
  194. package/layers/media/app/plugins/field-media.client.ts +0 -6
  195. package/layers/media/app/utils/ai-disclosure.ts +0 -19
  196. package/layers/media/app/utils/dnd.ts +0 -69
  197. package/layers/media/app/utils/library.ts +0 -81
  198. package/layers/media/app/utils/ops.ts +0 -60
  199. package/layers/media/app/utils/picture-model.ts +0 -125
  200. package/layers/media/app/utils/thumbhash.ts +0 -19
  201. package/layers/media/app/utils/variant-name.ts +0 -20
  202. package/layers/media/nuxt.config.ts +0 -7
  203. package/layers/media/server/middleware/ondemand-variants.ts +0 -38
  204. package/layers/media/server/plugins/01.register-media-pipelines.ts +0 -7
  205. package/layers/media/server/plugins/02.register-media.ts +0 -22
  206. package/layers/media/server/plugins/04.variant-capture.ts +0 -27
  207. package/layers/media/server/plugins/05.media-cleanup.ts +0 -5
  208. package/layers/media/server/tasks/media/backfill.ts +0 -17
  209. package/layers/public/app/app.vue +0 -5
  210. package/layers/public/app/components/BlockRenderer.vue +0 -56
  211. package/layers/public/app/components/KestrelLink.vue +0 -26
  212. package/layers/public/app/components/KestrelPreviewBridge.vue +0 -133
  213. package/layers/public/app/composables/public-page.ts +0 -24
  214. package/layers/public/app/error.vue +0 -12
  215. package/layers/public/app/layouts/default.vue +0 -5
  216. package/layers/public/app/pages/[...slug].vue +0 -239
  217. package/layers/public/app/pages/__kestrel/preview.vue +0 -46
  218. package/layers/public/app/utils/block-edit-context.ts +0 -16
  219. package/layers/public/app/utils/json-ld.ts +0 -139
  220. package/layers/public/app/utils/link-href.ts +0 -46
  221. package/layers/public/app/utils/page-head.ts +0 -84
  222. package/layers/public/app/utils/page-layout.ts +0 -18
  223. package/layers/public/app/utils/preview-protocol.ts +0 -117
  224. package/layers/public/app/utils/public-route.ts +0 -46
  225. package/layers/public/app/utils/site-head.ts +0 -43
  226. package/layers/public/modules/deploy-output/index.ts +0 -80
  227. package/layers/public/modules/prerender-routes/discover.ts +0 -37
  228. package/layers/public/modules/prerender-routes/index.ts +0 -88
  229. package/layers/public/modules/prerender-routes/routes.ts +0 -16
  230. package/layers/public/modules/prune-media/index.ts +0 -48
  231. package/layers/public/modules/prune-media/prune-media.ts +0 -81
  232. package/layers/public/nuxt.config.ts +0 -14
  233. package/layers/public/server/delivery-live/serve.ts +0 -111
  234. package/layers/public/server/middleware/delivery-live-catchall.ts +0 -7
  235. package/layers/public/server/pipelines/index.ts +0 -23
  236. package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +0 -14
  237. package/layers/public/server/plugins/01.register-public-pipelines.ts +0 -7
  238. package/layers/public/server/plugins/02.register-links.ts +0 -16
  239. package/layers/public/server/plugins/03.redirects.ts +0 -55
  240. package/layers/public/server/plugins/05.plan-publish.ts +0 -5
  241. package/layers/public/server/plugins/zz.publish.ts +0 -127
  242. package/layers/public/server/routes/llms-full.txt.get.ts +0 -99
  243. package/layers/public/server/routes/llms.txt.get.ts +0 -66
  244. package/layers/public/server/routes/redirects.json.get.ts +0 -58
  245. package/layers/public/server/routes/robots.txt.get.ts +0 -11
  246. package/layers/public/server/routes/sitemap.xml.get.ts +0 -79
  247. package/layers/public/server/tasks/publish/run.ts +0 -32
  248. package/layers/public/server/utils/publish/render-live.ts +0 -26
  249. package/layers/ui/app/assets/scss/_base.scss +0 -16
  250. package/layers/ui/app/assets/scss/_datepicker.scss +0 -82
  251. package/layers/ui/app/assets/scss/_mixins.scss +0 -50
  252. package/layers/ui/app/assets/scss/_reset.scss +0 -65
  253. package/layers/ui/app/assets/scss/_tokens.scss +0 -183
  254. package/layers/ui/app/assets/scss/main.scss +0 -4
  255. package/layers/ui/app/components/field/Boolean.vue +0 -30
  256. package/layers/ui/app/components/field/Choice.vue +0 -73
  257. package/layers/ui/app/components/field/Datetime.vue +0 -98
  258. package/layers/ui/app/components/field/DeadRefNote.vue +0 -28
  259. package/layers/ui/app/components/field/Json.vue +0 -62
  260. package/layers/ui/app/components/field/Layout.vue +0 -138
  261. package/layers/ui/app/components/field/Link.vue +0 -112
  262. package/layers/ui/app/components/field/LinkInternalPicker.vue +0 -95
  263. package/layers/ui/app/components/field/Number.vue +0 -30
  264. package/layers/ui/app/components/field/Relation.vue +0 -49
  265. package/layers/ui/app/components/field/Renderer.vue +0 -30
  266. package/layers/ui/app/components/field/Repeater.vue +0 -460
  267. package/layers/ui/app/components/field/Richtext.vue +0 -26
  268. package/layers/ui/app/components/field/Slug.vue +0 -65
  269. package/layers/ui/app/components/field/Text.vue +0 -35
  270. package/layers/ui/app/components/field/Unsupported.vue +0 -34
  271. package/layers/ui/app/components/ui/Alert.vue +0 -50
  272. package/layers/ui/app/components/ui/Brand.vue +0 -22
  273. package/layers/ui/app/components/ui/Button.vue +0 -116
  274. package/layers/ui/app/components/ui/ButtonGroup.vue +0 -96
  275. package/layers/ui/app/components/ui/Checkbox.vue +0 -53
  276. package/layers/ui/app/components/ui/CheckboxGroup.vue +0 -51
  277. package/layers/ui/app/components/ui/Combobox.vue +0 -345
  278. package/layers/ui/app/components/ui/DatePicker.vue +0 -92
  279. package/layers/ui/app/components/ui/DateRangePicker.vue +0 -100
  280. package/layers/ui/app/components/ui/Dialog.vue +0 -107
  281. package/layers/ui/app/components/ui/EmptyState.vue +0 -46
  282. package/layers/ui/app/components/ui/Field.vue +0 -58
  283. package/layers/ui/app/components/ui/Fieldset.vue +0 -65
  284. package/layers/ui/app/components/ui/Icon.vue +0 -44
  285. package/layers/ui/app/components/ui/Menu.vue +0 -54
  286. package/layers/ui/app/components/ui/NumberInput.vue +0 -175
  287. package/layers/ui/app/components/ui/Richtext.vue +0 -141
  288. package/layers/ui/app/components/ui/RichtextToolbar.vue +0 -251
  289. package/layers/ui/app/components/ui/Select.vue +0 -52
  290. package/layers/ui/app/components/ui/TextInput.vue +0 -111
  291. package/layers/ui/app/components/ui/Textarea.vue +0 -39
  292. package/layers/ui/app/components/ui/TimeInput.vue +0 -18
  293. package/layers/ui/app/components/ui/Toasts.vue +0 -103
  294. package/layers/ui/app/components/ui/Tooltip.vue +0 -45
  295. package/layers/ui/app/composables/useAdminLang.ts +0 -8
  296. package/layers/ui/app/composables/useDragReorder.ts +0 -41
  297. package/layers/ui/app/composables/useEchoGuard.ts +0 -28
  298. package/layers/ui/app/composables/useLinkField.ts +0 -70
  299. package/layers/ui/app/composables/useRecordOptions.ts +0 -84
  300. package/layers/ui/app/composables/useRepeater.ts +0 -99
  301. package/layers/ui/app/composables/useT.ts +0 -32
  302. package/layers/ui/app/composables/useToast.ts +0 -46
  303. package/layers/ui/app/i18n/de.ts +0 -432
  304. package/layers/ui/app/i18n/en.ts +0 -434
  305. package/layers/ui/app/utils/boolean-field.ts +0 -10
  306. package/layers/ui/app/utils/field-component.ts +0 -22
  307. package/layers/ui/app/utils/field-empty.ts +0 -21
  308. package/layers/ui/app/utils/field-registry.ts +0 -36
  309. package/layers/ui/app/utils/humanize.ts +0 -14
  310. package/layers/ui/app/utils/icons.ts +0 -143
  311. package/layers/ui/app/utils/iso-date.ts +0 -18
  312. package/layers/ui/app/utils/localized.ts +0 -13
  313. package/layers/ui/app/utils/record-context.ts +0 -15
  314. package/layers/ui/app/utils/reorder.ts +0 -8
  315. package/layers/ui/app/utils/richtext-preserve-class.ts +0 -31
  316. package/layers/ui/app/utils/useFieldA11y.ts +0 -40
  317. package/layers/ui/nuxt.config.ts +0 -9
  318. package/nuxt.config.ts +0 -108
  319. package/scripts/consumer-template-ci.mjs +0 -160
  320. package/scripts/copy-create-payload.mjs +0 -49
  321. package/scripts/coverage-all.mjs +0 -163
  322. package/scripts/dashboard.mjs +0 -89
  323. package/scripts/hash-password.mjs +0 -29
  324. package/scripts/kestrel.mjs +0 -240
  325. package/scripts/lib/cli.mjs +0 -129
  326. package/scripts/lib/coverage-model.mjs +0 -51
  327. package/scripts/lib/password.mjs +0 -19
  328. package/scripts/lib/scaffold.mjs +0 -205
  329. package/scripts/port-graph.mjs +0 -350
  330. package/scripts/typecheck.mjs +0 -62
  331. package/templates/starter/README.md +0 -65
  332. package/templates/starter/_env.example +0 -17
  333. package/templates/starter/_gitignore +0 -26
  334. package/templates/starter/_package.json +0 -22
  335. package/templates/starter/app/app.vue +0 -7
  336. package/templates/starter/app/blocks/Prose.vue +0 -13
  337. package/templates/starter/app/layouts/default.vue +0 -6
  338. package/templates/starter/nuxt.config.ts +0 -23
  339. package/templates/starter/pnpm-workspace.yaml +0 -8
  340. package/templates/starter/tsconfig.json +0 -3
@@ -1,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
- }