@michaelthielemann/kestrel 1.2.1

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 (395) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +12 -0
  3. package/README.md +234 -0
  4. package/kestrel.config.ts +28 -0
  5. package/layers/access/nuxt.config.ts +5 -0
  6. package/layers/access/server/middleware/00.ip-allowlist.ts +67 -0
  7. package/layers/access/server/middleware/access-guard.ts +30 -0
  8. package/layers/access/server/types.d.ts +8 -0
  9. package/layers/access/server/utils/csrf.ts +39 -0
  10. package/layers/access/server/utils/grant-registry.ts +36 -0
  11. package/layers/access/server/utils/guard.ts +44 -0
  12. package/layers/access/server/utils/ip-allowlist.ts +76 -0
  13. package/layers/access/server/utils/policy.ts +98 -0
  14. package/layers/access/server/utils/public-resources.ts +11 -0
  15. package/layers/access/server/utils/render-context.ts +41 -0
  16. package/layers/access/server/utils/require-admin.ts +15 -0
  17. package/layers/admin/app/components/AdminAccount.vue +212 -0
  18. package/layers/admin/app/components/AdminNav.vue +44 -0
  19. package/layers/admin/app/components/BlockFields.vue +68 -0
  20. package/layers/admin/app/components/BlockPreview.vue +401 -0
  21. package/layers/admin/app/components/BlockTree.vue +433 -0
  22. package/layers/admin/app/components/BlocksBody.vue +162 -0
  23. package/layers/admin/app/components/CollectionDeleteDialog.vue +38 -0
  24. package/layers/admin/app/components/CollectionEditor.vue +203 -0
  25. package/layers/admin/app/components/CollectionList.vue +1091 -0
  26. package/layers/admin/app/components/EditorStatus.vue +199 -0
  27. package/layers/admin/app/components/EditorUnsupported.vue +28 -0
  28. package/layers/admin/app/components/FieldsBody.vue +23 -0
  29. package/layers/admin/app/components/LocaleBar.vue +105 -0
  30. package/layers/admin/app/components/PageFields.vue +101 -0
  31. package/layers/admin/app/components/PageFieldsPane.vue +39 -0
  32. package/layers/admin/app/components/SeoFields.vue +169 -0
  33. package/layers/admin/app/components/SingletonEditor.vue +92 -0
  34. package/layers/admin/app/composables/useAuth.ts +68 -0
  35. package/layers/admin/app/composables/useBlockTree.ts +95 -0
  36. package/layers/admin/app/composables/useBlocks.ts +7 -0
  37. package/layers/admin/app/composables/useCachedResource.ts +35 -0
  38. package/layers/admin/app/composables/useCollectionOps.ts +53 -0
  39. package/layers/admin/app/composables/useCollections.ts +7 -0
  40. package/layers/admin/app/composables/useContentLocales.ts +23 -0
  41. package/layers/admin/app/composables/useEditForm.ts +370 -0
  42. package/layers/admin/app/composables/useLinkResolver.ts +40 -0
  43. package/layers/admin/app/composables/useListColumns.ts +53 -0
  44. package/layers/admin/app/composables/useListUrlState.ts +53 -0
  45. package/layers/admin/app/composables/usePendingPublishPoll.ts +15 -0
  46. package/layers/admin/app/composables/usePreviewViewport.ts +30 -0
  47. package/layers/admin/app/composables/usePublishStatus.ts +98 -0
  48. package/layers/admin/app/composables/useRailCollapsed.ts +14 -0
  49. package/layers/admin/app/composables/useTheme.ts +36 -0
  50. package/layers/admin/app/composables/useUnsavedGuard.ts +22 -0
  51. package/layers/admin/app/layouts/admin.vue +267 -0
  52. package/layers/admin/app/middleware/admin-auth.ts +4 -0
  53. package/layers/admin/app/pages/admin/[collection]/[id].vue +202 -0
  54. package/layers/admin/app/pages/admin/[collection]/index.vue +62 -0
  55. package/layers/admin/app/pages/admin/index.vue +118 -0
  56. package/layers/admin/app/pages/admin/login.vue +68 -0
  57. package/layers/admin/app/pages/admin/media.vue +28 -0
  58. package/layers/admin/app/pages/admin/references.vue +146 -0
  59. package/layers/admin/app/plugins/admin-style-guard.client.ts +76 -0
  60. package/layers/admin/app/plugins/reauth.client.ts +20 -0
  61. package/layers/admin/app/utils/admin-style-guard.ts +20 -0
  62. package/layers/admin/app/utils/block-tree.ts +214 -0
  63. package/layers/admin/app/utils/collection-ops.ts +44 -0
  64. package/layers/admin/app/utils/dead-refs.ts +30 -0
  65. package/layers/admin/app/utils/edit-form.ts +223 -0
  66. package/layers/admin/app/utils/editor-expose.ts +26 -0
  67. package/layers/admin/app/utils/editor-form-context.ts +83 -0
  68. package/layers/admin/app/utils/editor-registry.ts +20 -0
  69. package/layers/admin/app/utils/field-keys.ts +11 -0
  70. package/layers/admin/app/utils/list-columns.ts +141 -0
  71. package/layers/admin/app/utils/list-query.ts +96 -0
  72. package/layers/admin/app/utils/nav-active.ts +9 -0
  73. package/layers/admin/app/utils/populate-blocks.ts +288 -0
  74. package/layers/admin/app/utils/preview-channel.ts +62 -0
  75. package/layers/admin/app/utils/preview-viewport.ts +80 -0
  76. package/layers/admin/app/utils/reauth.ts +68 -0
  77. package/layers/admin/app/utils/record-title.ts +15 -0
  78. package/layers/admin/app/utils/register-builtin-editors.ts +9 -0
  79. package/layers/admin/app/utils/safe-redirect.ts +12 -0
  80. package/layers/admin/nuxt.config.ts +17 -0
  81. package/layers/auth/nuxt.config.ts +1 -0
  82. package/layers/auth/server/api/auth/login.post.ts +38 -0
  83. package/layers/auth/server/api/auth/logout.post.ts +9 -0
  84. package/layers/auth/server/api/auth/session.get.ts +1 -0
  85. package/layers/auth/server/utils/admin-credential.ts +42 -0
  86. package/layers/auth/server/utils/client-ip.ts +88 -0
  87. package/layers/auth/server/utils/login-throttle.ts +126 -0
  88. package/layers/auth/server/utils/password.ts +52 -0
  89. package/layers/auth/server/utils/session-cookie.ts +44 -0
  90. package/layers/auth/server/utils/session-epoch.ts +96 -0
  91. package/layers/auth/server/utils/session.ts +85 -0
  92. package/layers/collections/nuxt.config.ts +1 -0
  93. package/layers/collections/server/collections/pages.ts +19 -0
  94. package/layers/collections/server/plugins/01.register.ts +32 -0
  95. package/layers/collections/server/plugins/02.register-relation-populate.ts +17 -0
  96. package/layers/collections/server/utils/populate-relations.ts +66 -0
  97. package/layers/core/app/utils/bulk-actions.ts +9 -0
  98. package/layers/core/app/utils/filter-ops.ts +98 -0
  99. package/layers/core/app/utils/list-limits.ts +27 -0
  100. package/layers/core/app/utils/locale-path.ts +12 -0
  101. package/layers/core/app/utils/slugify.ts +16 -0
  102. package/layers/core/modules/auto-discovery/extract-block.ts +176 -0
  103. package/layers/core/modules/auto-discovery/index.ts +44 -0
  104. package/layers/core/modules/auto-discovery/scan.ts +78 -0
  105. package/layers/core/modules/auto-discovery/virtual.d.ts +12 -0
  106. package/layers/core/modules/kestrel/index.ts +86 -0
  107. package/layers/core/nuxt.config.ts +8 -0
  108. package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +8 -0
  109. package/layers/core/server/api/[collection]/[id]/translations.get.ts +4 -0
  110. package/layers/core/server/api/[collection]/[id].delete.ts +5 -0
  111. package/layers/core/server/api/[collection]/[id].get.ts +8 -0
  112. package/layers/core/server/api/[collection]/[id].patch.ts +8 -0
  113. package/layers/core/server/api/[collection]/bulk.post.ts +37 -0
  114. package/layers/core/server/api/[collection]/index.get.ts +19 -0
  115. package/layers/core/server/api/[collection]/index.post.ts +8 -0
  116. package/layers/core/server/api/[collection]/index.put.ts +7 -0
  117. package/layers/core/server/api/[collection]/options.get.ts +18 -0
  118. package/layers/core/server/api/[collection]/translations.get.ts +25 -0
  119. package/layers/core/server/api/collections/[name].get.ts +4 -0
  120. package/layers/core/server/api/collections/index.get.ts +3 -0
  121. package/layers/core/server/api/references/broken.get.ts +3 -0
  122. package/layers/core/server/api/references/referrers.get.ts +24 -0
  123. package/layers/core/server/database/record-refs.ts +20 -0
  124. package/layers/core/server/plugins/00.migrate.ts +11 -0
  125. package/layers/core/server/plugins/02.schema-sync.ts +63 -0
  126. package/layers/core/server/plugins/03.record-refs.ts +12 -0
  127. package/layers/core/server/schema/bootstrap.ts +41 -0
  128. package/layers/core/server/schema/desired.ts +99 -0
  129. package/layers/core/server/schema/dev-sync.ts +23 -0
  130. package/layers/core/server/schema/dialect.ts +59 -0
  131. package/layers/core/server/schema/diff.ts +110 -0
  132. package/layers/core/server/schema/introspect.ts +67 -0
  133. package/layers/core/server/schema/model.ts +61 -0
  134. package/layers/core/server/schema/render-sqlite.ts +89 -0
  135. package/layers/core/server/schema/sync.ts +201 -0
  136. package/layers/core/server/tasks/db/migrate.ts +54 -0
  137. package/layers/core/server/utils/blocks.ts +6 -0
  138. package/layers/core/server/utils/collection-types.ts +27 -0
  139. package/layers/core/server/utils/crud.ts +577 -0
  140. package/layers/core/server/utils/db.ts +26 -0
  141. package/layers/core/server/utils/defineCollection.ts +207 -0
  142. package/layers/core/server/utils/duplicate.ts +162 -0
  143. package/layers/core/server/utils/field-layout.ts +125 -0
  144. package/layers/core/server/utils/filter-predicate.ts +101 -0
  145. package/layers/core/server/utils/http.ts +50 -0
  146. package/layers/core/server/utils/if-unmodified.ts +13 -0
  147. package/layers/core/server/utils/kestrel-config.ts +410 -0
  148. package/layers/core/server/utils/key-lock.ts +33 -0
  149. package/layers/core/server/utils/locale.ts +53 -0
  150. package/layers/core/server/utils/page-route.ts +58 -0
  151. package/layers/core/server/utils/page-slug.ts +127 -0
  152. package/layers/core/server/utils/picker.ts +82 -0
  153. package/layers/core/server/utils/populate.ts +48 -0
  154. package/layers/core/server/utils/prerender-memo.ts +23 -0
  155. package/layers/core/server/utils/read-capture.ts +37 -0
  156. package/layers/core/server/utils/record-ref-index.ts +200 -0
  157. package/layers/core/server/utils/registry.ts +24 -0
  158. package/layers/core/server/utils/resolve-scope.ts +79 -0
  159. package/layers/core/server/utils/seo.ts +24 -0
  160. package/layers/core/server/utils/serialize-collection.ts +149 -0
  161. package/layers/core/server/utils/server-config.ts +18 -0
  162. package/layers/core/server/utils/sql.ts +2 -0
  163. package/layers/core/server/utils/storage.local.ts +117 -0
  164. package/layers/core/server/utils/storage.s3.ts +197 -0
  165. package/layers/core/server/utils/storage.ts +90 -0
  166. package/layers/core/server/utils/write-events.ts +31 -0
  167. package/layers/fields/app/utils/condition.ts +71 -0
  168. package/layers/fields/app/utils/define-block.ts +13 -0
  169. package/layers/fields/app/utils/field-constraints.ts +17 -0
  170. package/layers/fields/app/utils/field-factories.ts +129 -0
  171. package/layers/fields/app/utils/field-meta.ts +33 -0
  172. package/layers/fields/app/utils/field-validate.ts +75 -0
  173. package/layers/fields/app/utils/field-value.ts +7 -0
  174. package/layers/fields/app/utils/richtext-links.ts +48 -0
  175. package/layers/fields/nuxt.config.ts +1 -0
  176. package/layers/fields/server/api/blocks/index.get.ts +9 -0
  177. package/layers/fields/server/field-registry/index.ts +248 -0
  178. package/layers/fields/server/field-registry/naming.ts +10 -0
  179. package/layers/fields/server/field-registry/sanitize.ts +40 -0
  180. package/layers/fields/server/field-registry/types.ts +19 -0
  181. package/layers/fields/server/plugins/01.register-field-populate.ts +11 -0
  182. package/layers/fields/server/utils/block-ids.ts +43 -0
  183. package/layers/fields/server/utils/block-populate.ts +41 -0
  184. package/layers/fields/server/utils/buildCollection.ts +88 -0
  185. package/layers/fields/server/utils/buildFieldSchema.ts +22 -0
  186. package/layers/fields/server/utils/buildTable.ts +107 -0
  187. package/layers/fields/server/utils/conditional-required.ts +25 -0
  188. package/layers/fields/server/utils/defineBlock.ts +86 -0
  189. package/layers/fields/server/utils/defineFieldType.ts +33 -0
  190. package/layers/fields/server/utils/extract-refs.ts +152 -0
  191. package/layers/fields/server/utils/field-populate.ts +68 -0
  192. package/layers/media/app/components/KestrelImg.vue +65 -0
  193. package/layers/media/app/components/MediaConflictDialog.vue +35 -0
  194. package/layers/media/app/components/MediaDeleteDialog.vue +52 -0
  195. package/layers/media/app/components/MediaGrid.vue +113 -0
  196. package/layers/media/app/components/MediaImage.vue +70 -0
  197. package/layers/media/app/components/MediaLibrary.vue +451 -0
  198. package/layers/media/app/components/MediaNewFolderDialog.vue +32 -0
  199. package/layers/media/app/components/MediaPathBar.vue +163 -0
  200. package/layers/media/app/components/MediaPicker.vue +19 -0
  201. package/layers/media/app/components/MediaRenameDialog.vue +32 -0
  202. package/layers/media/app/components/MediaTable.vue +75 -0
  203. package/layers/media/app/components/MediaThumb.vue +25 -0
  204. package/layers/media/app/components/MediaToolbar.vue +90 -0
  205. package/layers/media/app/components/MediaUploadDialog.vue +87 -0
  206. package/layers/media/app/components/MediaViewer.vue +88 -0
  207. package/layers/media/app/components/field/Media.vue +261 -0
  208. package/layers/media/app/composables/useBlurUp.ts +55 -0
  209. package/layers/media/app/composables/useMediaClipboard.ts +14 -0
  210. package/layers/media/app/composables/useMediaContextMenu.ts +56 -0
  211. package/layers/media/app/composables/useMediaDnd.ts +56 -0
  212. package/layers/media/app/composables/useMediaLibrary.ts +151 -0
  213. package/layers/media/app/composables/useMediaOps.ts +50 -0
  214. package/layers/media/app/composables/useMediaRelocate.ts +39 -0
  215. package/layers/media/app/composables/useMediaResolver.ts +50 -0
  216. package/layers/media/app/composables/useMediaUpload.ts +93 -0
  217. package/layers/media/app/composables/useMediaVariant.ts +34 -0
  218. package/layers/media/app/plugins/field-media.client.ts +6 -0
  219. package/layers/media/app/utils/dnd.ts +69 -0
  220. package/layers/media/app/utils/library.ts +79 -0
  221. package/layers/media/app/utils/ops.ts +62 -0
  222. package/layers/media/app/utils/picture-model.ts +125 -0
  223. package/layers/media/app/utils/thumbhash.ts +19 -0
  224. package/layers/media/app/utils/variant-name.ts +20 -0
  225. package/layers/media/nuxt.config.ts +3 -0
  226. package/layers/media/server/api/media/[id]/usages.get.ts +8 -0
  227. package/layers/media/server/api/media/[id].delete.ts +18 -0
  228. package/layers/media/server/api/media/[id].get.ts +10 -0
  229. package/layers/media/server/api/media/[id].patch.ts +41 -0
  230. package/layers/media/server/api/media/backfill.post.ts +18 -0
  231. package/layers/media/server/api/media/copy.post.ts +7 -0
  232. package/layers/media/server/api/media/delete.post.ts +13 -0
  233. package/layers/media/server/api/media/folders.post.ts +18 -0
  234. package/layers/media/server/api/media/index.get.ts +15 -0
  235. package/layers/media/server/api/media/index.post.ts +157 -0
  236. package/layers/media/server/api/media/library.get.ts +17 -0
  237. package/layers/media/server/api/media/move.post.ts +7 -0
  238. package/layers/media/server/api/media/rename.post.ts +7 -0
  239. package/layers/media/server/api/media/resolve.get.ts +13 -0
  240. package/layers/media/server/collections/media-settings.ts +25 -0
  241. package/layers/media/server/collections/media.ts +29 -0
  242. package/layers/media/server/database/folders.ts +10 -0
  243. package/layers/media/server/middleware/ondemand-variants.ts +37 -0
  244. package/layers/media/server/plugins/02.register-media.ts +19 -0
  245. package/layers/media/server/plugins/03.media-cleanup.ts +20 -0
  246. package/layers/media/server/plugins/04.variant-capture.ts +23 -0
  247. package/layers/media/server/schema-tables/folders.ts +4 -0
  248. package/layers/media/server/tasks/media/backfill.ts +18 -0
  249. package/layers/media/server/utils/backfill.ts +132 -0
  250. package/layers/media/server/utils/derive.ts +59 -0
  251. package/layers/media/server/utils/folder-paths.ts +42 -0
  252. package/layers/media/server/utils/folders.ts +12 -0
  253. package/layers/media/server/utils/library.ts +90 -0
  254. package/layers/media/server/utils/media-cleanup.ts +27 -0
  255. package/layers/media/server/utils/media-enabled.ts +23 -0
  256. package/layers/media/server/utils/media-ops.ts +118 -0
  257. package/layers/media/server/utils/media-write.ts +13 -0
  258. package/layers/media/server/utils/naming.ts +52 -0
  259. package/layers/media/server/utils/ondemand.ts +69 -0
  260. package/layers/media/server/utils/persist-upload.ts +62 -0
  261. package/layers/media/server/utils/populate.ts +47 -0
  262. package/layers/media/server/utils/record.ts +63 -0
  263. package/layers/media/server/utils/relocate-ops.ts +341 -0
  264. package/layers/media/server/utils/relocate-plan.ts +63 -0
  265. package/layers/media/server/utils/resolve.ts +86 -0
  266. package/layers/media/server/utils/sanitize-svg.ts +12 -0
  267. package/layers/media/server/utils/sniff.ts +68 -0
  268. package/layers/media/server/utils/storage-relocate.ts +139 -0
  269. package/layers/media/server/utils/translations.ts +19 -0
  270. package/layers/media/server/utils/usages.ts +66 -0
  271. package/layers/media/server/utils/variant-capture.ts +70 -0
  272. package/layers/media/server/utils/variants.ts +108 -0
  273. package/layers/public/app/app.vue +5 -0
  274. package/layers/public/app/components/BlockRenderer.vue +56 -0
  275. package/layers/public/app/components/KestrelLink.vue +26 -0
  276. package/layers/public/app/components/KestrelPreviewBridge.vue +133 -0
  277. package/layers/public/app/composables/public-page.ts +24 -0
  278. package/layers/public/app/error.vue +12 -0
  279. package/layers/public/app/layouts/default.vue +5 -0
  280. package/layers/public/app/pages/[...slug].vue +157 -0
  281. package/layers/public/app/pages/__kestrel/preview.vue +34 -0
  282. package/layers/public/app/utils/block-edit-context.ts +16 -0
  283. package/layers/public/app/utils/link-href.ts +46 -0
  284. package/layers/public/app/utils/page-head.ts +84 -0
  285. package/layers/public/app/utils/preview-protocol.ts +81 -0
  286. package/layers/public/app/utils/public-route.ts +46 -0
  287. package/layers/public/modules/deploy-output/deploy-output.ts +370 -0
  288. package/layers/public/modules/deploy-output/index.ts +82 -0
  289. package/layers/public/modules/prerender-routes/discover.ts +37 -0
  290. package/layers/public/modules/prerender-routes/index.ts +85 -0
  291. package/layers/public/modules/prerender-routes/routes.ts +16 -0
  292. package/layers/public/modules/prune-media/index.ts +48 -0
  293. package/layers/public/modules/prune-media/prune-media.ts +81 -0
  294. package/layers/public/nuxt.config.ts +11 -0
  295. package/layers/public/server/api/links/resolve.get.ts +19 -0
  296. package/layers/public/server/api/publish-status.get.ts +44 -0
  297. package/layers/public/server/api/route.get.ts +18 -0
  298. package/layers/public/server/database/publish-deps.ts +12 -0
  299. package/layers/public/server/database/publish-status.ts +19 -0
  300. package/layers/public/server/plugins/02.register-links.ts +17 -0
  301. package/layers/public/server/plugins/zz.publish.ts +63 -0
  302. package/layers/public/server/routes/llms.txt.get.ts +72 -0
  303. package/layers/public/server/routes/robots.txt.get.ts +8 -0
  304. package/layers/public/server/routes/sitemap.xml.get.ts +66 -0
  305. package/layers/public/server/schema-tables/publish-deps.ts +4 -0
  306. package/layers/public/server/schema-tables/publish-status.ts +4 -0
  307. package/layers/public/server/tasks/publish/run.ts +26 -0
  308. package/layers/public/server/utils/link-resolve.ts +35 -0
  309. package/layers/public/server/utils/llms.ts +39 -0
  310. package/layers/public/server/utils/page-resolve.ts +86 -0
  311. package/layers/public/server/utils/populate-links.ts +66 -0
  312. package/layers/public/server/utils/publish/coalesce.ts +22 -0
  313. package/layers/public/server/utils/publish/deps-persistence.ts +43 -0
  314. package/layers/public/server/utils/publish/deps.ts +87 -0
  315. package/layers/public/server/utils/publish/invalidation.ts +141 -0
  316. package/layers/public/server/utils/publish/publish-status.ts +54 -0
  317. package/layers/public/server/utils/publish/publisher.ts +291 -0
  318. package/layers/public/server/utils/publish/queue.ts +67 -0
  319. package/layers/public/server/utils/publish/route-for-record.ts +16 -0
  320. package/layers/public/server/utils/publish/route-keys.ts +13 -0
  321. package/layers/public/server/utils/site-url.ts +32 -0
  322. package/layers/public/server/utils/sitemap.ts +97 -0
  323. package/layers/ui/app/assets/scss/_base.scss +16 -0
  324. package/layers/ui/app/assets/scss/_datepicker.scss +82 -0
  325. package/layers/ui/app/assets/scss/_mixins.scss +50 -0
  326. package/layers/ui/app/assets/scss/_reset.scss +65 -0
  327. package/layers/ui/app/assets/scss/_tokens.scss +183 -0
  328. package/layers/ui/app/assets/scss/main.scss +4 -0
  329. package/layers/ui/app/components/field/Boolean.vue +30 -0
  330. package/layers/ui/app/components/field/Choice.vue +68 -0
  331. package/layers/ui/app/components/field/Datetime.vue +96 -0
  332. package/layers/ui/app/components/field/DeadRefNote.vue +28 -0
  333. package/layers/ui/app/components/field/Json.vue +63 -0
  334. package/layers/ui/app/components/field/Layout.vue +140 -0
  335. package/layers/ui/app/components/field/Link.vue +112 -0
  336. package/layers/ui/app/components/field/LinkInternalPicker.vue +95 -0
  337. package/layers/ui/app/components/field/Number.vue +30 -0
  338. package/layers/ui/app/components/field/Relation.vue +49 -0
  339. package/layers/ui/app/components/field/Renderer.vue +30 -0
  340. package/layers/ui/app/components/field/Repeater.vue +460 -0
  341. package/layers/ui/app/components/field/Richtext.vue +26 -0
  342. package/layers/ui/app/components/field/Slug.vue +66 -0
  343. package/layers/ui/app/components/field/Text.vue +35 -0
  344. package/layers/ui/app/components/field/Unsupported.vue +34 -0
  345. package/layers/ui/app/components/ui/Alert.vue +50 -0
  346. package/layers/ui/app/components/ui/Brand.vue +22 -0
  347. package/layers/ui/app/components/ui/Button.vue +116 -0
  348. package/layers/ui/app/components/ui/ButtonGroup.vue +96 -0
  349. package/layers/ui/app/components/ui/Checkbox.vue +52 -0
  350. package/layers/ui/app/components/ui/CheckboxGroup.vue +50 -0
  351. package/layers/ui/app/components/ui/Combobox.vue +343 -0
  352. package/layers/ui/app/components/ui/DatePicker.vue +92 -0
  353. package/layers/ui/app/components/ui/DateRangePicker.vue +100 -0
  354. package/layers/ui/app/components/ui/Dialog.vue +107 -0
  355. package/layers/ui/app/components/ui/EmptyState.vue +46 -0
  356. package/layers/ui/app/components/ui/Field.vue +57 -0
  357. package/layers/ui/app/components/ui/Fieldset.vue +64 -0
  358. package/layers/ui/app/components/ui/Icon.vue +44 -0
  359. package/layers/ui/app/components/ui/Menu.vue +54 -0
  360. package/layers/ui/app/components/ui/NumberInput.vue +173 -0
  361. package/layers/ui/app/components/ui/Richtext.vue +119 -0
  362. package/layers/ui/app/components/ui/RichtextToolbar.vue +253 -0
  363. package/layers/ui/app/components/ui/Select.vue +51 -0
  364. package/layers/ui/app/components/ui/TextInput.vue +110 -0
  365. package/layers/ui/app/components/ui/Textarea.vue +38 -0
  366. package/layers/ui/app/components/ui/TimeInput.vue +17 -0
  367. package/layers/ui/app/components/ui/Toasts.vue +103 -0
  368. package/layers/ui/app/components/ui/Tooltip.vue +45 -0
  369. package/layers/ui/app/composables/useAdminLang.ts +8 -0
  370. package/layers/ui/app/composables/useDragReorder.ts +41 -0
  371. package/layers/ui/app/composables/useEchoGuard.ts +28 -0
  372. package/layers/ui/app/composables/useLinkField.ts +70 -0
  373. package/layers/ui/app/composables/useRecordOptions.ts +84 -0
  374. package/layers/ui/app/composables/useRepeater.ts +99 -0
  375. package/layers/ui/app/composables/useT.ts +32 -0
  376. package/layers/ui/app/composables/useToast.ts +46 -0
  377. package/layers/ui/app/i18n/de.ts +406 -0
  378. package/layers/ui/app/i18n/en.ts +440 -0
  379. package/layers/ui/app/utils/boolean-field.ts +10 -0
  380. package/layers/ui/app/utils/field-component.ts +22 -0
  381. package/layers/ui/app/utils/field-empty.ts +21 -0
  382. package/layers/ui/app/utils/field-registry.ts +36 -0
  383. package/layers/ui/app/utils/humanize.ts +14 -0
  384. package/layers/ui/app/utils/icons.ts +147 -0
  385. package/layers/ui/app/utils/iso-date.ts +18 -0
  386. package/layers/ui/app/utils/localized.ts +13 -0
  387. package/layers/ui/app/utils/record-context.ts +15 -0
  388. package/layers/ui/app/utils/reorder.ts +8 -0
  389. package/layers/ui/app/utils/richtext-preserve-class.ts +31 -0
  390. package/layers/ui/app/utils/useFieldA11y.ts +40 -0
  391. package/layers/ui/nuxt.config.ts +5 -0
  392. package/nuxt.config.ts +105 -0
  393. package/package.json +122 -0
  394. package/scripts/hash-password.mjs +26 -0
  395. package/scripts/typecheck.mjs +51 -0
@@ -0,0 +1,92 @@
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
+ const props = 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
+ function openPreview() {
21
+ const url = editorRef.value?.previewUrl
22
+ if (url) window.open(url, '_blank', 'noopener,noreferrer')
23
+ }
24
+
25
+ // Guard in-app navigation away from unsaved changes — both record-change AND same-record `?locale`
26
+ // switches (the LocaleBar's singleton locale switch reuses this route record → onBeforeRouteUpdate).
27
+ // The editor itself guards native unload.
28
+ useUnsavedGuard(() => editorRef.value?.dirty ?? false, () => t('editor.discardConfirm'))
29
+ </script>
30
+
31
+ <template>
32
+ <section class="singleton">
33
+ <div class="singleton__head">
34
+ <h1 class="singleton__title">{{ title }}</h1>
35
+ <div class="singleton__actions">
36
+ <UiButton type="button" variant="ghost" size="sm" icon="undo" :disabled="saving || !editorRef?.canUndo" :title="t('history.undo')" :aria-label="t('history.undo')" @click="editorRef?.undo()" />
37
+ <UiButton type="button" variant="ghost" size="sm" icon="redo" :disabled="saving || !editorRef?.canRedo" :title="t('history.redo')" :aria-label="t('history.redo')" @click="editorRef?.redo()" />
38
+ <UiButton v-if="editorRef?.previewUrl" type="button" variant="ghost" size="sm" icon="external-link" :title="t('editor.openInNewTab')" :aria-label="t('editor.openInNewTab')" @click="openPreview" />
39
+ <UiButton type="submit" :form="EDITOR_FORM_ID" variant="primary" size="sm" icon="check" :loading="saving">{{ t('common.save') }}</UiButton>
40
+ <EditorStatus 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" />
41
+ </div>
42
+ </div>
43
+ <CollectionEditor
44
+ ref="editorRef"
45
+ :collection="collection"
46
+ id="single"
47
+ :form-id="EDITOR_FORM_ID"
48
+ :locale-param="localeParam"
49
+ :actions="false"
50
+ @saved="onSaved"
51
+ />
52
+ </section>
53
+ </template>
54
+
55
+ <style lang="scss">
56
+ // The singleton shell stays fixed and fills the height handed down; the editor body (.editor__flat /
57
+ // .editor3) owns the scroll — same model as the record editor (see the admin layout).
58
+ .singleton {
59
+ display: flex;
60
+ flex-direction: column;
61
+ gap: var(--space-4);
62
+ flex: 1 1 auto;
63
+ min-height: 0;
64
+ overflow: hidden;
65
+
66
+ // Fixed header; the fields scroll below it, so it needs no sticky positioning (matching .record__head).
67
+ &__head {
68
+ flex: 0 0 auto;
69
+ display: flex;
70
+ align-items: center;
71
+ justify-content: space-between;
72
+ gap: var(--space-4);
73
+ padding-bottom: var(--space-3);
74
+ border-bottom: 1px solid var(--color-border);
75
+ }
76
+ &__title {
77
+ font-size: var(--text-xl);
78
+ font-weight: var(--weight-bold);
79
+ text-transform: capitalize;
80
+ }
81
+ &__actions {
82
+ display: flex;
83
+ align-items: center;
84
+ gap: var(--space-2);
85
+ }
86
+ &__ampel {
87
+ margin-inline-start: var(--space-1);
88
+ padding-inline-start: var(--space-3);
89
+ border-inline-start: 1px solid var(--color-border);
90
+ }
91
+ }
92
+ </style>
@@ -0,0 +1,68 @@
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/auth/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/auth/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/auth/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
+ }
@@ -0,0 +1,95 @@
1
+ import { ref, computed } from 'vue'
2
+ import type { Ref } from 'vue'
3
+ import { useEchoGuard } from '../../../ui/app/composables/useEchoGuard'
4
+ import type { SerializedBlock } from '../../../core/server/utils/serialize-collection'
5
+ import {
6
+ type BlockRow,
7
+ findInTree,
8
+ updatePropById,
9
+ removeById,
10
+ removalRetarget,
11
+ moveById,
12
+ duplicateById,
13
+ addBlock,
14
+ } from '../utils/block-tree'
15
+
16
+ /**
17
+ * Reactive state for the 3-pane block editor: one nested `content` tree plus a single shared selection
18
+ * (the stable `block.id`, or `null` for the page root). The tree pane and the fields pane both drive
19
+ * the same instance — the tree selects + does structural ops, the fields pane edits the selected
20
+ * block's props. Every mutation is id-addressed and immutable (see `block-tree.ts`) and re-emits the
21
+ * whole array (so `useEditForm`'s `setField('content', …)` reconciliation still runs). `genId` is
22
+ * injectable for deterministic tests; defaults to `crypto.randomUUID` (the server requires a non-empty id).
23
+ */
24
+ export function useBlockTree(
25
+ model: Ref<unknown[] | null | undefined>,
26
+ schemas: Ref<Record<string, SerializedBlock>>,
27
+ genId: () => string = () => crypto.randomUUID(),
28
+ // Routes an emit through the caller's own undo-history capture point instead of a plain `model.value =`
29
+ // assignment, tagged with a per-op coalesce key (see `emit`) — so a structural op landing inside another
30
+ // edit's coalesce window (e.g. a delete right after typing) still gets its own undo step.
31
+ setContent?: (value: unknown[], coalesceAs: string) => void,
32
+ ) {
33
+ // JSON round-trip (not structuredClone — which throws on Vue reactive proxies): reads through the
34
+ // proxy into detached plain objects. Block content is JSON by definition, so this is lossless here.
35
+ const clone = (v: unknown): BlockRow[] => (Array.isArray(v) ? (JSON.parse(JSON.stringify(v)) as BlockRow[]) : [])
36
+
37
+ const blocks = ref<BlockRow[]>(clone(model.value))
38
+ const selectedId = ref<string | null>(null)
39
+
40
+ function emit(coalesceAs: string): void {
41
+ if (setContent) setContent(blocks.value, coalesceAs)
42
+ else model.value = blocks.value
43
+ }
44
+
45
+ // Echo-guard against the tree's OWN emit (shared primitive): an external change (load / locale copy /
46
+ // undo / redo) differs from the current tree, so it reseeds and drops a now-missing selection to root.
47
+ useEchoGuard(model, () => blocks.value, (v) => {
48
+ blocks.value = clone(v)
49
+ if (selectedId.value !== null && !findInTree(blocks.value, selectedId.value)) selectedId.value = null
50
+ }, [])
51
+
52
+ const selectedBlock = computed<BlockRow | null>(() =>
53
+ selectedId.value === null ? null : (findInTree(blocks.value, selectedId.value)?.block ?? null),
54
+ )
55
+
56
+ function select(id: string | null): void {
57
+ selectedId.value = id
58
+ }
59
+
60
+ function setProp(id: string, key: string, value: unknown): void {
61
+ blocks.value = updatePropById(blocks.value, id, key, value)
62
+ // Coalesces a typing burst into that one field, same as an ordinary text field would.
63
+ emit(`content:prop:${id}:${key}`)
64
+ }
65
+
66
+ function remove(id: string): void {
67
+ const retarget = removalRetarget(blocks.value, id)
68
+ blocks.value = removeById(blocks.value, id)
69
+ // Heal the selection whenever it no longer exists — covers removing the selected block itself AND
70
+ // removing an ANCESTOR of it (the whole subtree goes); land on the removed block's sibling/parent.
71
+ if (selectedId.value !== null && !findInTree(blocks.value, selectedId.value)) selectedId.value = retarget
72
+ emit(`content:remove:${id}`)
73
+ }
74
+
75
+ function move(id: string, dir: -1 | 1): void {
76
+ blocks.value = moveById(blocks.value, id, dir)
77
+ emit(`content:move:${id}`)
78
+ }
79
+
80
+ function duplicate(id: string): void {
81
+ const { tree, newId } = duplicateById(blocks.value, id, genId)
82
+ blocks.value = tree
83
+ selectedId.value = newId
84
+ emit(`content:duplicate:${id}`)
85
+ }
86
+
87
+ function add(parentId: string | null, slotName: string | null, type: string): void {
88
+ const { tree, newId } = addBlock(blocks.value, parentId, slotName, type, schemas.value, genId)
89
+ blocks.value = tree
90
+ selectedId.value = newId
91
+ emit(`content:add:${parentId}:${slotName}:${type}`)
92
+ }
93
+
94
+ return { blocks, selectedId, selectedBlock, select, setProp, remove, move, duplicate, add }
95
+ }
@@ -0,0 +1,7 @@
1
+ import type { SerializedBlock } from '../../../core/server/utils/serialize-collection'
2
+
3
+ // Thin wrapper over the shared cached-resource loader; keeps the named `blocks` return.
4
+ export function useBlocks() {
5
+ const { state: blocks, load } = useCachedResource<SerializedBlock>('kestrel-blocks', '/api/blocks')
6
+ return { blocks, load }
7
+ }
@@ -0,0 +1,35 @@
1
+ // Generic cached-resource loader: a `useState`-backed list that fetches once and is shared by every
2
+ // caller. Backs `useBlocks` / `useCollections` (structurally identical loaders).
3
+ //
4
+ // SSR isolation: the in-flight (dedup) promise is keyed first by the per-request Nuxt app (a WeakMap,
5
+ // so it can't leak across SSR requests) AND then by `stateKey` — otherwise two different resources
6
+ // loading concurrently in ONE request would share a single promise and cross-contaminate (one
7
+ // resolves with the other's data).
8
+ const inflight = new WeakMap<object, Map<string, Promise<unknown>>>()
9
+
10
+ export function useCachedResource<T>(stateKey: string, url: string) {
11
+ const state = useState<T[] | null>(stateKey, () => null)
12
+ const nuxtApp = useNuxtApp()
13
+
14
+ async function load(): Promise<T[]> {
15
+ if (state.value) return state.value
16
+ let perApp = inflight.get(nuxtApp)
17
+ if (!perApp) {
18
+ perApp = new Map()
19
+ inflight.set(nuxtApp, perApp)
20
+ }
21
+ let pending = perApp.get(stateKey) as Promise<T[]> | undefined
22
+ if (!pending) {
23
+ pending = $fetch<{ data: T[] }>(url)
24
+ .then(({ data }) => {
25
+ state.value = data
26
+ return data
27
+ })
28
+ .finally(() => perApp!.delete(stateKey))
29
+ perApp.set(stateKey, pending)
30
+ }
31
+ return pending
32
+ }
33
+
34
+ return { state, load }
35
+ }
@@ -0,0 +1,53 @@
1
+ import { ref, toValue, type MaybeRefOrGetter } from 'vue'
2
+ import { buildDeleteReport, type BatchAction, type BatchDeleteReport, type BulkResult, type ReferrerCounts } from '../utils/collection-ops'
3
+
4
+ /**
5
+ * Client orchestration for the collection batch operations — ONE module, two entry points: a row action
6
+ * passes a single id, the bulk bar passes many. Every mutating call hits the one command endpoint
7
+ * (`POST /api/{collection}/bulk`); the server does the batching, the client never loops N requests.
8
+ * Mirrors media/useMediaOps (busy/error + an onChanged refetch hook).
9
+ */
10
+ export function useCollectionOps(collection: MaybeRefOrGetter<string>, onChanged?: () => void | Promise<void>) {
11
+ const { t } = useT()
12
+ const busy = ref(false)
13
+ const error = ref<string | null>(null)
14
+ const name = () => toValue(collection)
15
+
16
+ async function bulk(action: BatchAction, ids: number[]): Promise<BulkResult> {
17
+ busy.value = true
18
+ error.value = null
19
+ try {
20
+ const res = await $fetch<BulkResult>(`/api/${name()}/bulk`, { method: 'POST', body: { action, ids } })
21
+ await onChanged?.()
22
+ return res
23
+ } catch (e) {
24
+ error.value = (e as { statusMessage?: string })?.statusMessage ?? t('list.opFailed')
25
+ throw e
26
+ } finally {
27
+ busy.value = false
28
+ }
29
+ }
30
+
31
+ /** Read-only pre-delete summary (referrer aggregate). Best-effort: sets `error` and rethrows on failure
32
+ * so the caller can decide whether to still proceed (deletion is not blocked on the warning lookup). */
33
+ async function previewDelete(ids: number[]): Promise<BatchDeleteReport> {
34
+ error.value = null
35
+ try {
36
+ const res = await $fetch<ReferrerCounts>('/api/references/referrers', { query: { collection: name(), ids: ids.join(',') } })
37
+ // The endpoint flags whether the index lookup ran; carry that through rather than inferring safety from
38
+ // an empty `counts`. A missing body is indeterminate too — it is no evidence that nothing links here.
39
+ return buildDeleteReport(ids, res?.counts ?? {}, res != null && res.checked !== false)
40
+ } catch (e) {
41
+ error.value = (e as { statusMessage?: string })?.statusMessage ?? t('list.refCheckFailed')
42
+ throw e
43
+ }
44
+ }
45
+
46
+ const confirmDelete = (ids: number[]) => bulk('delete', ids)
47
+ const duplicate = (ids: number[]) => bulk('duplicate', ids)
48
+ /** Persist a new status; the server's write pipeline (emitWrite) turns that into a publish/unpublish. */
49
+ const setStatus = (ids: number[], status: 'published' | 'draft') =>
50
+ bulk(status === 'published' ? 'publish' : 'unpublish', ids)
51
+
52
+ return { busy, error, previewDelete, confirmDelete, duplicate, setStatus }
53
+ }
@@ -0,0 +1,7 @@
1
+ import type { SerializedCollection } from '../../../core/server/utils/serialize-collection'
2
+
3
+ // Thin wrapper over the shared cached-resource loader; keeps the named `collections` return.
4
+ export function useCollections() {
5
+ const { state: collections, load } = useCachedResource<SerializedCollection>('kestrel-collections', '/api/collections')
6
+ return { collections, load }
7
+ }
@@ -0,0 +1,23 @@
1
+ export interface ContentLocales {
2
+ /** Website/content locales (NOT the admin-UI language) — what the editor LocaleBar offers. */
3
+ locales: string[]
4
+ primary: string
5
+ /** Whether the primary locale is prefixed in public URLs too (`/en/about`); the `localePath` flag. */
6
+ prefixPrimary: boolean
7
+ }
8
+
9
+ /** Resolve the configured content locales from a `runtimeConfig.public`-shaped object. Pure, so the
10
+ * fallback/primary logic is unit-testable without a Nuxt context. The `['en']` fallback is a safe
11
+ * last resort for an absent/empty config (nuxt.config always sets `public.locales`): primary-only is
12
+ * a safer guess than assuming a second locale exists, so it intentionally differs from the server's
13
+ * `['en','de']` *default* (which applies only when KESTREL_LOCALES itself is unset). */
14
+ export function pickContentLocales(pub: { locales?: unknown; primaryLocale?: unknown; prefixPrimary?: unknown }): ContentLocales {
15
+ const locales = Array.isArray(pub.locales) && pub.locales.length ? pub.locales.map(String) : ['en']
16
+ const primary =
17
+ typeof pub.primaryLocale === 'string' && locales.includes(pub.primaryLocale) ? pub.primaryLocale : locales[0]!
18
+ return { locales, primary, prefixPrimary: pub.prefixPrimary === true }
19
+ }
20
+
21
+ export function useContentLocales(): ContentLocales {
22
+ return pickContentLocales(useRuntimeConfig().public as { locales?: unknown; primaryLocale?: unknown; prefixPrimary?: unknown })
23
+ }