@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,14 @@
1
+ /**
2
+ * Collapsed/expanded state for the admin navigation rail.
3
+ * Backed by a cookie so the choice persists across reloads and stays SSR-safe
4
+ * (no hydration mismatch — server and client read the same value).
5
+ */
6
+ export function useRailCollapsed() {
7
+ const collapsed = useCookie<boolean>('kestrel-rail-collapsed', { default: () => false })
8
+
9
+ function toggle() {
10
+ collapsed.value = !collapsed.value
11
+ }
12
+
13
+ return { collapsed, toggle }
14
+ }
@@ -0,0 +1,36 @@
1
+ export type ThemeName = 'light' | 'dark'
2
+
3
+ /**
4
+ * The initial admin theme: an explicit stored choice wins; otherwise follow the OS
5
+ * `prefers-color-scheme`; default to light. Pure so it can be unit-tested without a DOM.
6
+ */
7
+ export function resolveInitialTheme(opts: { stored?: string | null; prefersDark?: boolean }): ThemeName {
8
+ if (opts.stored === 'light' || opts.stored === 'dark') return opts.stored
9
+ return opts.prefersDark ? 'dark' : 'light'
10
+ }
11
+
12
+ /**
13
+ * Admin colour theme (light/dark). The explicit choice is cookie-backed so it persists and is
14
+ * SSR-safe (server and client read the same value — no hydration mismatch). Until a choice is made
15
+ * the theme follows the OS preference, picked up client-side. The admin layout binds `theme` onto
16
+ * `<html data-theme>`; `_tokens.scss` keys the palette off that attribute.
17
+ */
18
+ export function useTheme() {
19
+ const stored = useCookie<ThemeName | null>('kestrel-admin-theme', { default: () => null })
20
+ const prefersDark = ref(false)
21
+ let mq: MediaQueryList | null = null
22
+ const onChange = (e: MediaQueryListEvent) => { prefersDark.value = e.matches }
23
+
24
+ onMounted(() => {
25
+ mq = window.matchMedia('(prefers-color-scheme: dark)')
26
+ prefersDark.value = mq.matches
27
+ mq.addEventListener('change', onChange)
28
+ })
29
+ onUnmounted(() => mq?.removeEventListener('change', onChange))
30
+
31
+ const theme = computed<ThemeName>(() => resolveInitialTheme({ stored: stored.value, prefersDark: prefersDark.value }))
32
+ const setTheme = (t: ThemeName) => { stored.value = t }
33
+ const toggle = () => setTheme(theme.value === 'dark' ? 'light' : 'dark')
34
+
35
+ return { theme, toggle, setTheme }
36
+ }
@@ -0,0 +1,22 @@
1
+ import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
2
+
3
+ /**
4
+ * Confirm before navigating away from unsaved editor changes. Registers BOTH route guards on purpose:
5
+ * `onBeforeRouteLeave` fires when the matched route record is dropped (editor → list, rail nav), but
6
+ * Vue Router fires `onBeforeRouteUpdate` instead when a param/query-only change reuses the SAME record —
7
+ * which is exactly what the LocaleBar does (edit a sibling translation `/admin/{coll}/{id}`,
8
+ * create-and-link `/admin/{coll}/new?…`, switch a singleton's `?locale`). Without the update guard those
9
+ * same-record transitions silently discard in-progress edits (the `key: route.fullPath` remount only
10
+ * happens AFTER the router has already committed the navigation, so it can't veto it).
11
+ *
12
+ * `isDirty` / `skip` / `message` are read lazily on each navigation so the guard always sees current
13
+ * state. Returning `false` from a guard cancels the navigation; `skip()` lets a post-save / delete
14
+ * redirect pass straight through.
15
+ */
16
+ export function useUnsavedGuard(isDirty: () => boolean, message: () => string, skip: () => boolean = () => false): void {
17
+ const guard = () => {
18
+ if (!skip() && isDirty() && !confirm(message())) return false
19
+ }
20
+ onBeforeRouteLeave(guard)
21
+ onBeforeRouteUpdate(guard)
22
+ }
@@ -0,0 +1,267 @@
1
+ <script setup lang="ts">
2
+ // The admin design system (tokens / reset / base) loads HERE — with the admin-layout chunk — rather than
3
+ // app-globally, so it stays scoped to /admin and never leaks Kestrel's reset onto a consumer's public site
4
+ // (which ships its own CSS, e.g. Bootstrap). Every admin route uses this layout, so coverage is complete.
5
+ import '../../../ui/app/assets/scss/main.scss'
6
+
7
+ const { authenticated } = useAuth()
8
+ const { collapsed, toggle: toggleRail } = useRailCollapsed()
9
+ const { theme, toggle: toggleTheme } = useTheme()
10
+ const { t } = useT()
11
+
12
+ // Bind the resolved theme onto <html> so the admin palette (and teleported widgets) pick it up, and
13
+ // re-assert the root font-size while the admin is mounted. The admin is entirely rem-based and assumes
14
+ // the browser-default root; a consumer that sets `html { font-size: … }` on its public site would
15
+ // otherwise rescale the whole panel (rem resolves against the shared root, which no admin-side selector
16
+ // can override). An inline style beats a consumer stylesheet rule in the cascade without needing
17
+ // `!important`; `initial` restores the user-agent default (respecting the visitor's own font-size
18
+ // preference). unhead removes both attributes when the admin layout unmounts, so the public site keeps
19
+ // its own root. In dev the style guard also strips the consumer sheet outright (see
20
+ // admin-style-guard.client.ts); this reassert is the production-safe half of the defense.
21
+ // No admin page sets its own <title> today, so this fallback is the only one every route gets — a
22
+ // per-page useHead still wins (titleTemplate only fires when a title is actually set).
23
+ useHead(() => ({
24
+ title: 'Kestrel',
25
+ titleTemplate: (title) => (title && title !== 'Kestrel' ? `${title} · Kestrel` : 'Kestrel'),
26
+ htmlAttrs: { 'data-theme': theme.value, style: 'font-size: initial' },
27
+ }))
28
+ </script>
29
+
30
+ <template>
31
+ <div class="admin" :class="{ 'admin--rail-collapsed': collapsed }">
32
+ <aside v-if="authenticated" class="admin__rail">
33
+ <div class="rail__head">
34
+ <NuxtLink to="/admin" class="rail__brand" aria-label="Kestrel">
35
+ <UiBrand />
36
+ <span class="rail__brand-word rail__label">kestrel</span>
37
+ </NuxtLink>
38
+ <button
39
+ type="button"
40
+ class="rail__toggle"
41
+ :aria-label="collapsed ? t('a11y.expandSidebar') : t('a11y.collapseSidebar')"
42
+ @click="toggleRail"
43
+ >
44
+ <UiIcon :name="collapsed ? 'panel-left-open' : 'panel-left-close'" size="1.25rem" />
45
+ </button>
46
+ </div>
47
+
48
+ <div class="rail__nav">
49
+ <NuxtLink
50
+ to="/admin"
51
+ class="rail__item rail__item--dashboard"
52
+ :title="collapsed ? t('nav.dashboard') : undefined"
53
+ >
54
+ <UiIcon name="home" class="rail__icon" size="1.25rem" />
55
+ <span class="rail__label">{{ t('nav.dashboard') }}</span>
56
+ </NuxtLink>
57
+
58
+ <AdminNav />
59
+ </div>
60
+
61
+ <div class="rail__foot">
62
+ <AdminAccount />
63
+ <button
64
+ type="button"
65
+ class="rail__item"
66
+ :title="collapsed ? (theme === 'dark' ? t('theme.light') : t('theme.dark')) : undefined"
67
+ :aria-label="theme === 'dark' ? t('a11y.toLight') : t('a11y.toDark')"
68
+ @click="toggleTheme"
69
+ >
70
+ <UiIcon :name="theme === 'dark' ? 'sun' : 'moon'" class="rail__icon" size="1.25rem" />
71
+ <span class="rail__label">{{ theme === 'dark' ? t('theme.light') : t('theme.dark') }}</span>
72
+ </button>
73
+ </div>
74
+ </aside>
75
+
76
+ <main class="admin__main"><slot /></main>
77
+ <UiToasts />
78
+ </div>
79
+ </template>
80
+
81
+ <style lang="scss">
82
+ .admin {
83
+ display: flex;
84
+ align-items: flex-start;
85
+ min-height: 100svh;
86
+ background: var(--color-bg);
87
+
88
+ &__rail {
89
+ position: sticky;
90
+ top: 0;
91
+ flex: 0 0 var(--rail-width);
92
+ width: var(--rail-width);
93
+ height: 100svh;
94
+ display: flex;
95
+ flex-direction: column;
96
+ background: var(--color-rail-bg);
97
+ border-right: 1px solid var(--color-border);
98
+ transition:
99
+ flex-basis var(--motion-base) var(--ease-standard),
100
+ width var(--motion-base) var(--ease-standard);
101
+ }
102
+
103
+ // App-shell: the main column is locked to the viewport height and never scrolls itself — each page
104
+ // (and, in the editor, each of its panes) owns its own scroll region instead.
105
+ &__main {
106
+ flex: 1 1 auto;
107
+ min-width: 0;
108
+ height: 100svh;
109
+ overflow: hidden;
110
+ display: flex;
111
+ flex-direction: column;
112
+ padding: var(--space-4);
113
+ }
114
+
115
+ // Scroll-ownership convention — every admin page follows it. The page section handed this
116
+ // `flex:1 1 auto; min-height:0` is a flex column that NEVER scrolls itself (overflow:hidden); it keeps
117
+ // its header/toolbar fixed (flex:0 0 auto) and delegates vertical scroll to exactly ONE inner region
118
+ // (flex:1 1 auto; min-height:0; overflow:auto). One unbroken min-height:0 chain reaches that region, so
119
+ // nothing else clips (inline popovers stay visible). Those regions, by page: the list table
120
+ // (.list__scroll), the flat/blocks editor body (.editor__flat / .editor3 panes), the media grid
121
+ // (.media-library__items) and the references table (.refs__scroll).
122
+ &__main > * {
123
+ flex: 1 1 auto;
124
+ min-height: 0;
125
+ }
126
+
127
+ // Collapsed rail: narrow to icon-only width.
128
+ &--rail-collapsed &__rail {
129
+ flex-basis: var(--rail-width-collapsed);
130
+ width: var(--rail-width-collapsed);
131
+ }
132
+ }
133
+
134
+ .rail {
135
+ &__head {
136
+ display: flex;
137
+ align-items: center;
138
+ justify-content: space-between;
139
+ gap: var(--space-2);
140
+ padding: var(--space-4) var(--space-3);
141
+ }
142
+
143
+ &__brand {
144
+ display: inline-flex;
145
+ align-items: center;
146
+ gap: var(--space-2);
147
+ min-width: 0;
148
+ color: var(--color-text);
149
+ text-decoration: none;
150
+ font-weight: var(--weight-bold);
151
+ border-radius: var(--radius-sm);
152
+
153
+ &:focus-visible {
154
+ outline: 2px solid var(--color-focus);
155
+ outline-offset: -2px;
156
+ }
157
+ }
158
+
159
+ &__toggle {
160
+ display: inline-flex;
161
+ align-items: center;
162
+ justify-content: center;
163
+ flex-shrink: 0;
164
+ padding: var(--space-1);
165
+ border: 0;
166
+ border-radius: var(--radius-sm);
167
+ background: transparent;
168
+ color: var(--color-text-muted);
169
+ cursor: pointer;
170
+
171
+ &:hover {
172
+ background: var(--color-hover);
173
+ color: var(--color-text);
174
+ }
175
+ &:focus-visible {
176
+ outline: 2px solid var(--color-focus);
177
+ outline-offset: -2px;
178
+ }
179
+ }
180
+
181
+ &__nav {
182
+ flex: 1 1 auto;
183
+ overflow-y: auto;
184
+ display: flex;
185
+ flex-direction: column;
186
+ gap: var(--space-1);
187
+ padding: var(--space-2) var(--space-3);
188
+ }
189
+
190
+ // Shared item used by the static links, the collection links (AdminNav) and Sign out.
191
+ &__item {
192
+ display: flex;
193
+ align-items: center;
194
+ gap: var(--space-3);
195
+ width: 100%;
196
+ padding: var(--space-2) var(--space-3);
197
+ border: 0;
198
+ border-radius: var(--radius-md);
199
+ background: transparent;
200
+ color: var(--color-text);
201
+ font: inherit;
202
+ font-size: var(--text-sm);
203
+ text-align: left;
204
+ text-decoration: none;
205
+ cursor: pointer;
206
+
207
+ &:hover {
208
+ background: var(--color-hover);
209
+ }
210
+ &:focus-visible {
211
+ outline: 2px solid var(--color-focus);
212
+ outline-offset: -2px;
213
+ }
214
+ // Calm active state: a neutral fill (not a tinted pill) with the icon carrying a restrained accent.
215
+ &.router-link-active {
216
+ background: var(--color-active, var(--color-surface-2));
217
+ color: var(--color-text);
218
+ font-weight: var(--weight-medium);
219
+ }
220
+ }
221
+
222
+ &__item.router-link-active &__icon {
223
+ color: var(--color-primary);
224
+ }
225
+
226
+ &__icon {
227
+ flex-shrink: 0;
228
+ }
229
+
230
+ &__label {
231
+ flex: 1 1 auto;
232
+ min-width: 0;
233
+ overflow: hidden;
234
+ text-overflow: ellipsis;
235
+ white-space: nowrap;
236
+ }
237
+
238
+ &__foot {
239
+ padding: var(--space-3);
240
+ border-top: 1px solid var(--color-border);
241
+ }
242
+
243
+ }
244
+
245
+ // Collapsed state: hide labels (kept in the DOM for a11y + tests), center icons.
246
+ .admin--rail-collapsed {
247
+ .rail__label {
248
+ position: absolute;
249
+ width: 1px;
250
+ height: 1px;
251
+ padding: 0;
252
+ margin: -1px;
253
+ overflow: hidden;
254
+ clip: rect(0, 0, 0, 0);
255
+ white-space: nowrap;
256
+ border: 0;
257
+ }
258
+ .rail__item {
259
+ justify-content: center;
260
+ gap: 0;
261
+ }
262
+ .rail__head {
263
+ flex-direction: column;
264
+ align-items: center;
265
+ }
266
+ }
267
+ </style>
@@ -0,0 +1,4 @@
1
+ export default defineNuxtRouteMiddleware(async (to) => {
2
+ if (await useAuth().ensureSession()) return
3
+ return navigateTo(`/admin/login?redirect=${encodeURIComponent(to.fullPath)}`)
4
+ })
@@ -0,0 +1,202 @@
1
+ <script setup lang="ts">
2
+ import { resolveLocalized } from '../../../../../ui/app/utils/localized'
3
+ import type { EditorExpose } from '../../../utils/editor-expose'
4
+ import type { BatchDeleteReport } from '../../../utils/collection-ops'
5
+
6
+ definePageMeta({ layout: 'admin', middleware: 'admin-auth', key: (route) => route.fullPath })
7
+
8
+ const route = useRoute()
9
+ const collection = route.params.collection as string
10
+ const id = route.params.id as string
11
+ // Content locale + translation group flow from the URL (set by the LocaleBar). The page `key` is the
12
+ // full path, so switching locale remounts the editor cleanly with the new locale/group.
13
+ const localeParam = computed(() => (typeof route.query.locale === 'string' ? route.query.locale.trim() || undefined : undefined))
14
+ const group = computed(() => (typeof route.query.group === 'string' ? route.query.group.trim() || undefined : undefined))
15
+ // Returning to the list keeps the locale we were editing in, so a multilingual workflow stays in context.
16
+ const listPath = computed(() => `/admin/${collection}${localeParam.value ? `?locale=${localeParam.value}` : ''}`)
17
+
18
+ const { t, lang } = useT()
19
+ const toast = useToast()
20
+ // Resolve the collection's display labels so the header reads "New Page" / "Posts", not the raw
21
+ // (often plural) route param. Non-critical chrome — a failed load falls back to the param.
22
+ const { load: loadCollections } = useCollections()
23
+ const def = (await loadCollections().catch(() => [])).find((c) => c.name === collection) ?? null
24
+ const singular = computed(() => resolveLocalized(def?.label?.singular, lang.value) ?? collection)
25
+ const plural = computed(() => resolveLocalized(def?.label?.plural, lang.value) ?? collection)
26
+ // The "create" heading prefers the collection's complete per-locale phrase (same one the list button
27
+ // uses), so a German title reads "Neue Seite" / "Neuer Beitrag" — never "Neu: …".
28
+ const newTitle = computed(() => resolveLocalized(def?.label?.new, lang.value) ?? t('editor.newRecord', { collection: singular.value }))
29
+ // The header (where Delete also lives) drives Save by submitting the editor's form via its id, and reads
30
+ // the editor's in-flight `saving` for the button state. `dirty` powers the unsaved-changes guard.
31
+ const EDITOR_FORM_ID = 'record-editor'
32
+ const editorRef = ref<EditorExpose | null>(null)
33
+ const saving = computed(() => editorRef.value?.saving ?? false)
34
+ // An id means nothing to an editor, so the heading shows the record's own title when it has one. A new (or
35
+ // still untitled) record has nothing but its id, and the generic phrase is the only thing that reads well.
36
+ const recordName = computed(() => editorRef.value?.recordTitle?.trim() ?? '')
37
+ const hasRecordTitle = computed(() => id !== 'new' && recordName.value !== '')
38
+ const heading = computed(() =>
39
+ id === 'new'
40
+ ? newTitle.value
41
+ : hasRecordTitle.value
42
+ ? recordName.value
43
+ : t('editor.editRecord', { collection: singular.value, id }),
44
+ )
45
+
46
+ // Open the record's real public URL in a new tab — the live preview (drafts included, served to the
47
+ // authenticated admin by the public render path). Withheld until there's a saved, routable page.
48
+ function openPreview() {
49
+ const url = editorRef.value?.previewUrl
50
+ if (url) window.open(url, '_blank', 'noopener,noreferrer')
51
+ }
52
+ const skipGuard = ref(false)
53
+
54
+ // Delete flows through the shared batch op + the same confirm dialog the list uses, so the delete logic
55
+ // lives ONCE. On success the composable's onChanged navigates back to the list (bypassing the dirty guard).
56
+ const { busy: deleting, error: deleteError, previewDelete, confirmDelete: runDelete } = useCollectionOps(collection, async () => {
57
+ skipGuard.value = true
58
+ await navigateTo(listPath.value)
59
+ })
60
+ const deleteOpen = ref(false)
61
+ const deleteReport = ref<BatchDeleteReport | null>(null)
62
+
63
+ function onSaved(record: unknown) {
64
+ toast.success(t('toast.saved'))
65
+ // Save only saves — it does not leave the editor (navigation is the back link / rail's job). The one
66
+ // exception: a brand-new record switches to its own editor URL so further saves update it (id is now
67
+ // known) instead of POSTing a duplicate.
68
+ if (id === 'new') {
69
+ const newId = (record as { id?: number | string } | null)?.id
70
+ if (newId != null) {
71
+ skipGuard.value = true
72
+ return navigateTo(`/admin/${collection}/${newId}${localeParam.value ? `?locale=${localeParam.value}` : ''}`)
73
+ }
74
+ }
75
+ }
76
+
77
+ // Guard unsaved edits on BOTH route-record changes (→ list) AND same-record param/query nav (the
78
+ // LocaleBar's edit-sibling / create-translation links reuse this `[id]` record → onBeforeRouteUpdate).
79
+ useUnsavedGuard(() => editorRef.value?.dirty ?? false, () => t('editor.discardConfirm'), () => skipGuard.value)
80
+
81
+ function toList() {
82
+ return navigateTo(listPath.value)
83
+ }
84
+
85
+ // Open the confirm dialog, fetching the referrer aggregate for its warning. Best-effort: a failed lookup
86
+ // still opens the dialog with a bare summary (deleting is never blocked on the warning).
87
+ async function onDelete() {
88
+ deleteReport.value = null
89
+ deleteOpen.value = true
90
+ try {
91
+ deleteReport.value = await previewDelete([Number(id)])
92
+ } catch {
93
+ // The referrer lookup FAILED — mark the report unverified (checked:false) so the dialog shows the
94
+ // "references could not be verified" caution instead of a reassuring referencedCount:0 (which would
95
+ // read as a confirmed-safe delete).
96
+ deleteReport.value = { count: 1, referencedCount: 0, referenced: [], checked: false }
97
+ deleteError.value = null
98
+ }
99
+ }
100
+ async function confirmDelete() {
101
+ try {
102
+ await runDelete([Number(id)])
103
+ toast.success(t('toast.deleted'))
104
+ deleteOpen.value = false
105
+ // navigation back to the list is the composable's onChanged
106
+ } catch {
107
+ // deleteError stays surfaced in the dialog
108
+ }
109
+ }
110
+ </script>
111
+
112
+ <template>
113
+ <section class="record">
114
+ <div class="record__head">
115
+ <NuxtLink :to="listPath" class="record__back">
116
+ <UiIcon name="arrow-left" :size="16" />
117
+ <span>{{ t('editor.back', { collection: plural }) }}</span>
118
+ </NuxtLink>
119
+ <h1 class="record__title" :class="{ 'record__title--generic': !hasRecordTitle }">{{ heading }}</h1>
120
+ <div class="record__actions">
121
+ <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()" />
122
+ <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()" />
123
+ <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" />
124
+ <UiButton type="button" variant="secondary" size="sm" icon="x" :disabled="saving" @click="toList">{{ t('common.cancel') }}</UiButton>
125
+ <UiButton v-if="id !== 'new'" variant="danger" size="sm" icon="trash" :loading="deleting" @click="onDelete">{{ t('common.delete') }}</UiButton>
126
+ <UiButton type="submit" :form="EDITOR_FORM_ID" variant="primary" size="sm" icon="check" :loading="saving">{{ t('common.save') }}</UiButton>
127
+ <EditorStatus class="record__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" />
128
+ </div>
129
+ </div>
130
+ <CollectionEditor ref="editorRef" :collection="collection" :id="id" :form-id="EDITOR_FORM_ID" :locale-param="localeParam" :group="group" :actions="false" @saved="onSaved" />
131
+ <CollectionDeleteDialog
132
+ :open="deleteOpen"
133
+ :report="deleteReport"
134
+ :busy="deleting"
135
+ :error="deleteError"
136
+ @update:open="deleteOpen = $event"
137
+ @confirm="confirmDelete"
138
+ />
139
+ </section>
140
+ </template>
141
+
142
+ <style lang="scss">
143
+ .record {
144
+ display: flex;
145
+ flex-direction: column;
146
+ gap: var(--space-4);
147
+ // The record shell stays fixed; the editor's panes own the scroll (see the admin layout).
148
+ overflow: hidden;
149
+
150
+ &__back {
151
+ display: inline-flex;
152
+ align-items: center;
153
+ gap: var(--space-1);
154
+ flex: 0 0 auto;
155
+ font-size: var(--text-sm);
156
+ color: var(--color-text-muted);
157
+ text-decoration: none;
158
+
159
+ &:hover {
160
+ color: var(--color-text);
161
+ }
162
+ span {
163
+ text-transform: capitalize;
164
+ }
165
+ }
166
+ // Back link, heading and actions share ONE row so the editor keeps the vertical space for its panes.
167
+ &__head {
168
+ flex: 0 0 auto;
169
+ display: flex;
170
+ align-items: center;
171
+ gap: var(--space-3);
172
+ flex-wrap: wrap;
173
+ }
174
+ &__actions {
175
+ display: flex;
176
+ align-items: center;
177
+ gap: var(--space-2);
178
+ margin-left: auto;
179
+ }
180
+ // The status Ampel sits far-right, divided from the action buttons.
181
+ &__ampel {
182
+ margin-inline-start: var(--space-1);
183
+ padding-inline-start: var(--space-3);
184
+ border-inline-start: 1px solid var(--color-border);
185
+ }
186
+ &__title {
187
+ font-size: var(--text-xl);
188
+ font-weight: var(--weight-bold);
189
+ // A long page title must not push the actions off the row.
190
+ min-width: 0;
191
+ overflow: hidden;
192
+ text-overflow: ellipsis;
193
+ white-space: nowrap;
194
+
195
+ // Only the generic "Edit {collection} #{id}" / "New {collection}" phrase is title-cased — a real record
196
+ // title is shown verbatim (capitalize would mangle e.g. "iPhone").
197
+ &--generic {
198
+ text-transform: capitalize;
199
+ }
200
+ }
201
+ }
202
+ </style>
@@ -0,0 +1,62 @@
1
+ <script setup lang="ts">
2
+ import { resolveLocalized } from '../../../../../ui/app/utils/localized'
3
+
4
+ // Key on collection + locale ONLY (not the full path): a collection- or locale-change still remounts (which
5
+ // resets committed filter/draft/page/selection from the clean target URL), but a sort/page/perPage/filter
6
+ // query change must NOT remount — CollectionList derives that state from the URL in place, keeping the filter
7
+ // panel open and focus intact. ([id].vue deliberately keeps `route.fullPath` — it needs locale/group/id.)
8
+ definePageMeta({
9
+ layout: 'admin',
10
+ middleware: 'admin-auth',
11
+ key: (route) => `${route.params.collection}::${typeof route.query.locale === 'string' ? route.query.locale : ''}`,
12
+ })
13
+
14
+ const route = useRoute()
15
+ const collection = route.params.collection as string
16
+ const localeParam = computed(() => (typeof route.query.locale === 'string' ? route.query.locale.trim() || undefined : undefined))
17
+ const { primary } = useContentLocales()
18
+
19
+ const { t, lang } = useT()
20
+ const { load } = useCollections()
21
+ const collections = await load()
22
+ const def = collections.find((c) => c.name === collection) ?? null
23
+ // The content locale the list browses: the ?locale or the primary, for a translatable collection only.
24
+ const listLocale = computed(() => (def?.translatable ? (localeParam.value || primary) : undefined))
25
+ </script>
26
+
27
+ <template>
28
+ <section class="collection">
29
+ <p v-if="!def" class="collection__missing">{{ t('collection.unknown', { name: collection }) }}</p>
30
+
31
+ <template v-else-if="def.mode === 'single'">
32
+ <SingletonEditor :collection="collection" :title="resolveLocalized(def.label?.singular, lang) ?? collection" :locale-param="localeParam" />
33
+ </template>
34
+
35
+ <template v-else>
36
+ <h1 class="collection__title">{{ resolveLocalized(def.label?.plural, lang) ?? collection }}</h1>
37
+ <CollectionList :schema="def" :locale="listLocale" />
38
+ </template>
39
+ </section>
40
+ </template>
41
+
42
+ <style lang="scss">
43
+ // Non-scrolling passthrough: hands its bounded height to whichever branch renders (the singleton
44
+ // editor, or the list title + CollectionList). It never scrolls itself — the inner region does.
45
+ .collection {
46
+ display: flex;
47
+ flex-direction: column;
48
+ gap: var(--space-5);
49
+ min-height: 0;
50
+ overflow: hidden;
51
+
52
+ &__title {
53
+ flex: 0 0 auto;
54
+ font-size: var(--text-xl);
55
+ font-weight: var(--weight-bold);
56
+ text-transform: capitalize;
57
+ }
58
+ &__missing {
59
+ color: var(--color-text-muted);
60
+ }
61
+ }
62
+ </style>