@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,110 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed } from 'vue'
3
+
4
+ // `$attrs` (id / aria-* from UiField, autocomplete, …) is bound to the inner <input>, not the wrapper,
5
+ // so the field-label association + error wiring keep working with the reveal button in place.
6
+ defineOptions({ inheritAttrs: false })
7
+
8
+ const props = withDefaults(
9
+ defineProps<{
10
+ type?: 'text' | 'password' | 'email' | 'url' | 'search' | 'tel'
11
+ placeholder?: string
12
+ disabled?: boolean
13
+ /** Force the show/hide reveal affordance on a non-password input. Password inputs get it automatically. */
14
+ reveal?: boolean
15
+ /** Tighter padding for dense chrome (toolbars). Still meets the AA minimum target size. */
16
+ slim?: boolean
17
+ }>(),
18
+ { type: 'text', disabled: false, reveal: false, slim: false },
19
+ )
20
+
21
+ const model = defineModel<string | null>()
22
+ const { t } = useT()
23
+
24
+ // Reveal is automatic for password inputs (the only consumer today), opt-in elsewhere via `reveal`.
25
+ const revealable = computed(() => props.type === 'password' || props.reveal)
26
+ const revealed = ref(false)
27
+ // While revealed, a password input becomes a text input so the typed value is legible; other types
28
+ // are unaffected. The control is a switch — the rendered type tracks `revealed` exactly.
29
+ const inputType = computed(() => (revealable.value && revealed.value ? 'text' : props.type))
30
+ </script>
31
+
32
+ <template>
33
+ <div class="ui-input-wrap">
34
+ <input
35
+ v-model="model"
36
+ :type="inputType"
37
+ :placeholder="placeholder"
38
+ :disabled="disabled"
39
+ class="ui-input"
40
+ :class="{ 'ui-input--revealable': revealable, 'ui-input--slim': slim }"
41
+ v-bind="$attrs"
42
+ >
43
+ <button
44
+ v-if="revealable"
45
+ type="button"
46
+ class="ui-input__reveal"
47
+ :disabled="disabled"
48
+ :aria-label="revealed ? t('input.hidePassword') : t('input.showPassword')"
49
+ @click="revealed = !revealed"
50
+ >
51
+ <UiIcon :name="revealed ? 'eye-off' : 'eye'" size="1.125rem" />
52
+ </button>
53
+ </div>
54
+ </template>
55
+
56
+ <style lang="scss">
57
+ @use '../../assets/scss/mixins' as *;
58
+
59
+ .ui-input-wrap {
60
+ position: relative;
61
+ display: block;
62
+ width: 100%;
63
+ }
64
+
65
+ .ui-input {
66
+ @include input-base;
67
+ width: 100%;
68
+
69
+ &::placeholder {
70
+ color: var(--color-text-muted);
71
+ }
72
+
73
+ &--slim {
74
+ @include input-slim;
75
+ }
76
+
77
+ // Leave room for the inset reveal button so the value never sits under it. Declared after `--slim` so a
78
+ // slim revealable input keeps the reveal gutter (equal specificity → source order wins).
79
+ &--revealable {
80
+ padding-inline-end: 2.5rem;
81
+ }
82
+ }
83
+
84
+ .ui-input__reveal {
85
+ @include focus-ring;
86
+ position: absolute;
87
+ inset-block: 1px;
88
+ inset-inline-end: 1px;
89
+ display: inline-flex;
90
+ align-items: center;
91
+ justify-content: center;
92
+ // ≥24×24 target (WCAG 2.5.8): full input height, comfortable hit width.
93
+ min-inline-size: 2.25rem;
94
+ padding-inline: var(--space-2);
95
+ border: 0;
96
+ border-start-end-radius: var(--radius-md);
97
+ border-end-end-radius: var(--radius-md);
98
+ background: transparent;
99
+ color: var(--color-text-muted);
100
+ cursor: pointer;
101
+
102
+ &:hover:not(:disabled) {
103
+ color: var(--color-text);
104
+ }
105
+ &:disabled {
106
+ cursor: not-allowed;
107
+ opacity: 0.6;
108
+ }
109
+ }
110
+ </style>
@@ -0,0 +1,38 @@
1
+ <script setup lang="ts">
2
+ withDefaults(
3
+ defineProps<{
4
+ rows?: number
5
+ placeholder?: string
6
+ disabled?: boolean
7
+ }>(),
8
+ { rows: 4, disabled: false },
9
+ )
10
+
11
+ const model = defineModel<string | null>()
12
+ </script>
13
+
14
+ <template>
15
+ <textarea
16
+ v-model="model"
17
+ :rows="rows"
18
+ :placeholder="placeholder"
19
+ :disabled="disabled"
20
+ class="ui-textarea"
21
+ />
22
+ </template>
23
+
24
+ <style lang="scss">
25
+ @use '../../assets/scss/mixins' as *;
26
+
27
+ .ui-textarea {
28
+ @include input-base;
29
+ width: 100%;
30
+ font-family: inherit;
31
+ line-height: var(--leading-normal);
32
+ resize: vertical;
33
+
34
+ &::placeholder {
35
+ color: var(--color-text-muted);
36
+ }
37
+ }
38
+ </style>
@@ -0,0 +1,17 @@
1
+ <script setup lang="ts">
2
+ withDefaults(defineProps<{ disabled?: boolean }>(), { disabled: false })
3
+
4
+ const model = defineModel<string | null>()
5
+ </script>
6
+
7
+ <template>
8
+ <input v-model="model" type="time" :disabled="disabled" class="ui-time">
9
+ </template>
10
+
11
+ <style lang="scss">
12
+ @use '../../assets/scss/mixins' as *;
13
+
14
+ .ui-time {
15
+ @include input-base;
16
+ }
17
+ </style>
@@ -0,0 +1,103 @@
1
+ <script setup lang="ts">
2
+ // App-wide transient notifications. Teleported to <body> so it floats above any layout/dialog;
3
+ // the queue lives in `useToast` (unit-tested), this is the thin render of it.
4
+ const toasts = useToast()
5
+ const { t } = useT()
6
+ </script>
7
+
8
+ <template>
9
+ <Teleport to="body">
10
+ <!-- Two PERSISTENT live regions that exist before any toast: a screen reader only announces changes to
11
+ a live region already in the DOM, so a role placed on the freshly-inserted toast node is missed.
12
+ The visual cards below carry no live role; these mirror their text (errors → assertive). -->
13
+ <div class="ui-toasts__live" aria-live="polite" role="status">
14
+ <template v-for="toast in toasts.items" :key="`p-${toast.id}`">
15
+ <div v-if="toast.type !== 'error'">{{ toast.message }}</div>
16
+ </template>
17
+ </div>
18
+ <div class="ui-toasts__live" aria-live="assertive" role="alert">
19
+ <template v-for="toast in toasts.items" :key="`a-${toast.id}`">
20
+ <div v-if="toast.type === 'error'">{{ toast.message }}</div>
21
+ </template>
22
+ </div>
23
+ <div class="ui-toasts" role="region" :aria-label="t('toast.region')">
24
+ <TransitionGroup name="ui-toast">
25
+ <div
26
+ v-for="toast in toasts.items"
27
+ :key="toast.id"
28
+ class="ui-toast"
29
+ :class="`ui-toast--${toast.type}`"
30
+ >
31
+ <span class="ui-toast__msg">{{ toast.message }}</span>
32
+ <button type="button" class="ui-toast__close" :aria-label="t('toast.dismiss')" @click="toasts.dismiss(toast.id)">
33
+ <UiIcon name="x" size="0.875rem" />
34
+ </button>
35
+ </div>
36
+ </TransitionGroup>
37
+ </div>
38
+ </Teleport>
39
+ </template>
40
+
41
+ <style lang="scss">
42
+ @use '../../assets/scss/mixins' as *;
43
+
44
+ .ui-toasts__live { @include sr-only; }
45
+
46
+ .ui-toasts {
47
+ position: fixed;
48
+ inset-block-end: var(--space-5);
49
+ inset-inline-end: var(--space-5);
50
+ z-index: var(--z-toast);
51
+ display: flex;
52
+ flex-direction: column;
53
+ gap: var(--space-2);
54
+ width: min(22rem, calc(100vw - var(--space-8)));
55
+ pointer-events: none;
56
+ }
57
+
58
+ .ui-toast {
59
+ pointer-events: auto;
60
+ display: flex;
61
+ align-items: center;
62
+ gap: var(--space-3);
63
+ padding: var(--space-3) var(--space-3) var(--space-3) var(--space-4);
64
+ background: var(--color-surface);
65
+ color: var(--color-text);
66
+ border: 1px solid var(--color-border);
67
+ border-inline-start: 4px solid var(--color-border-strong);
68
+ border-radius: var(--radius-md);
69
+ box-shadow: var(--shadow-md);
70
+ font-size: var(--text-sm);
71
+
72
+ &--success { border-inline-start-color: var(--color-success); }
73
+ &--error { border-inline-start-color: var(--color-danger); }
74
+ &--info { border-inline-start-color: var(--color-primary); }
75
+
76
+ &__msg { flex: 1 1 auto; min-width: 0; }
77
+
78
+ &__close {
79
+ @include focus-ring;
80
+ flex-shrink: 0;
81
+ display: inline-flex;
82
+ padding: var(--space-1);
83
+ border: 0;
84
+ border-radius: var(--radius-sm);
85
+ background: transparent;
86
+ color: var(--color-text-muted);
87
+
88
+ &:hover { background: var(--color-hover); color: var(--color-text); }
89
+ }
90
+ }
91
+
92
+ .ui-toast-enter-active,
93
+ .ui-toast-leave-active {
94
+ transition:
95
+ opacity var(--motion-base) var(--ease-standard),
96
+ transform var(--motion-base) var(--ease-standard);
97
+ }
98
+ .ui-toast-enter-from,
99
+ .ui-toast-leave-to {
100
+ opacity: 0;
101
+ transform: translateY(var(--space-3));
102
+ }
103
+ </style>
@@ -0,0 +1,45 @@
1
+ <script setup lang="ts">
2
+ import { TooltipProvider, TooltipRoot, TooltipTrigger, TooltipPortal, TooltipContent } from 'reka-ui'
3
+
4
+ // A hover/focus tooltip primitive (auto-imported as `UiTooltip`). The default slot is the trigger
5
+ // (`as-child`, so it must be a single element); the `content` slot is the floating panel. Self-contained
6
+ // `TooltipProvider` so it works anywhere without an app-root provider. PORTALED (escapes the action-bar's
7
+ // `overflow`/stacking context), so its styles are global — see below.
8
+ withDefaults(defineProps<{ side?: 'top' | 'right' | 'bottom' | 'left'; delay?: number }>(), {
9
+ side: 'top',
10
+ delay: 150,
11
+ })
12
+ </script>
13
+
14
+ <template>
15
+ <TooltipProvider :delay-duration="delay">
16
+ <TooltipRoot>
17
+ <TooltipTrigger as-child>
18
+ <slot />
19
+ </TooltipTrigger>
20
+ <TooltipPortal>
21
+ <TooltipContent class="ui-tooltip" :side="side" :side-offset="6" :collision-padding="8">
22
+ <slot name="content" />
23
+ </TooltipContent>
24
+ </TooltipPortal>
25
+ </TooltipRoot>
26
+ </TooltipProvider>
27
+ </template>
28
+
29
+ <style lang="scss">
30
+ // Not scoped: Reka teleports the content to <body>, where a scoped data-v selector no longer matches
31
+ // (the same reason UiMenu and the other portaled overlays keep global styles — see the
32
+ // `reka-portal-scoped-styles` note). `--z-toast` keeps it above dropdowns/dialogs it may overlap.
33
+ .ui-tooltip {
34
+ max-width: 22rem;
35
+ padding: var(--space-2) var(--space-3);
36
+ background: var(--color-surface);
37
+ color: var(--color-text);
38
+ border: 1px solid var(--color-border);
39
+ border-radius: var(--radius-md);
40
+ box-shadow: var(--shadow-md);
41
+ font-size: var(--text-sm);
42
+ line-height: var(--leading-normal);
43
+ z-index: var(--z-toast);
44
+ }
45
+ </style>
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The admin-UI language preference (chrome strings), cookie-backed so it persists and is SSR-safe.
3
+ * Deliberately distinct from the content locale (`useContentLocales` / the editor LocaleBar): one is
4
+ * the language of the dashboard, the other is the language of the content being edited.
5
+ */
6
+ export function useAdminLang() {
7
+ return useCookie<string>('kestrel-admin-lang', { default: () => 'en' })
8
+ }
@@ -0,0 +1,41 @@
1
+ import { ref } from 'vue'
2
+
3
+ /**
4
+ * The shared HTML drag-and-drop REORDER interaction (chip lists, media tiles, …): owns the `dragIndex` /
5
+ * `overIndex` state and the five dnd handlers, so a widget only supplies `disabled` and a `commit(from,
6
+ * to)` that actually reorders its model (and announces the move). The index passed to the handlers is
7
+ * whatever the widget renders by (a model index for the combobox, a resolved/subsequence index for the
8
+ * media field) — `commit` maps it.
9
+ */
10
+ export function useDragReorder(opts: { disabled: () => boolean; commit: (from: number, to: number) => void }) {
11
+ const dragIndex = ref<number | null>(null)
12
+ const overIndex = ref<number | null>(null)
13
+
14
+ function onDragStart(index: number, event: DragEvent) {
15
+ if (opts.disabled()) return
16
+ dragIndex.value = index
17
+ if (event.dataTransfer) {
18
+ event.dataTransfer.effectAllowed = 'move'
19
+ event.dataTransfer.setData('text/plain', String(index)) // Firefox needs data set to start the drag
20
+ }
21
+ }
22
+ function onDragEnter(index: number) {
23
+ if (dragIndex.value !== null) overIndex.value = index
24
+ }
25
+ // Clear the drop highlight only when the cursor leaves the list entirely (not on item-to-item moves).
26
+ function onDragLeave(event: DragEvent) {
27
+ if (!(event.currentTarget as Element).contains(event.relatedTarget as Node | null)) overIndex.value = null
28
+ }
29
+ function onDrop(index: number) {
30
+ if (opts.disabled() || dragIndex.value === null) return
31
+ if (dragIndex.value !== index) opts.commit(dragIndex.value, index)
32
+ dragIndex.value = null
33
+ overIndex.value = null
34
+ }
35
+ function onDragEnd() {
36
+ dragIndex.value = null
37
+ overIndex.value = null
38
+ }
39
+
40
+ return { dragIndex, overIndex, onDragStart, onDragEnter, onDragLeave, onDrop, onDragEnd }
41
+ }
@@ -0,0 +1,28 @@
1
+ import { watch } from 'vue'
2
+ import type { Ref } from 'vue'
3
+
4
+ /**
5
+ * The shared "mirror a v-model into local reactive state, skip the echo of our own emit, reseed on a
6
+ * genuine external change" guard — used by the block tree, the repeater and the link field.
7
+ *
8
+ * It compares the incoming model against the CURRENT local snapshot, NOT a one-shot "last emitted" token.
9
+ * A captured token goes stale: an external undo→redo (or copy-from-locale) can land back on a
10
+ * previously-emitted value and be wrongly skipped, stranding local state at the pre-undo content. The
11
+ * own-emit echo still short-circuits (the model we just wrote equals our snapshot), while any genuine
12
+ * difference reseeds.
13
+ *
14
+ * @param current the local serializable snapshot (what we emit / compare against)
15
+ * @param reseed apply an external model value into local state
16
+ * @param empty the normalized form of a null/undefined model for the comparison (e.g. `[]` for a list)
17
+ */
18
+ export function useEchoGuard<T>(model: Ref<T>, current: () => unknown, reseed: (v: T) => void, empty: unknown = null): void {
19
+ watch(model, (v) => {
20
+ // Own-emit fast path: writing `model.value` to our own reactive snapshot (e.g. `model.value =
21
+ // blocks.value`) resolves both sides to the SAME proxy, so a reference check catches it for free —
22
+ // skipping a full JSON.stringify of a potentially large tree on every keystroke. Excluded from
23
+ // null/undefined so the `empty` normalization below still runs for that case.
24
+ if (v !== null && v !== undefined && v === current()) return
25
+ if (JSON.stringify(v ?? empty) === JSON.stringify(current())) return
26
+ reseed(v)
27
+ })
28
+ }
@@ -0,0 +1,70 @@
1
+ import { ref, computed, watch } from 'vue'
2
+ import type { Ref } from 'vue'
3
+ import { useEchoGuard } from './useEchoGuard'
4
+ import type { LinkValue, LinkType } from '../../../core/server/utils/defineCollection'
5
+
6
+ export function useLinkField(
7
+ model: Ref<LinkValue | null | undefined>,
8
+ allowed: Ref<LinkType[]>,
9
+ ) {
10
+ const initial = model.value
11
+
12
+ const currentType = ref<LinkType>(
13
+ initial?.type ?? allowed.value[0] ?? 'external',
14
+ )
15
+ const label = ref(initial?.label ?? '')
16
+ const url = ref(initial?.type === 'external' ? initial.url : '')
17
+ const email = ref(initial?.type === 'email' ? initial.email : '')
18
+ const tel = ref(initial?.type === 'tel' ? initial.tel : '')
19
+ const collection = ref<string | null>(initial?.type === 'internal' ? initial.collection : null)
20
+ const recordId = ref<number | null>(initial?.type === 'internal' ? initial.id : null)
21
+ const hash = ref(initial?.type === 'internal' ? (initial.hash ?? '') : '')
22
+
23
+ function build(): LinkValue | null {
24
+ const lbl = label.value.trim() || undefined
25
+ const type = currentType.value
26
+ if (type === 'external') {
27
+ const u = url.value.trim()
28
+ return u ? { type: 'external', url: u, ...(lbl ? { label: lbl } : {}) } : null
29
+ }
30
+ if (type === 'email') {
31
+ const e = email.value.trim()
32
+ return e ? { type: 'email', email: e, ...(lbl ? { label: lbl } : {}) } : null
33
+ }
34
+ if (type === 'tel') {
35
+ const t = tel.value.trim()
36
+ return t ? { type: 'tel', tel: t, ...(lbl ? { label: lbl } : {}) } : null
37
+ }
38
+ if (collection.value && recordId.value != null) {
39
+ const h = hash.value.trim().replace(/^#+/, '')
40
+ return { type: 'internal', collection: collection.value, id: recordId.value, ...(h ? { hash: h } : {}), ...(lbl ? { label: lbl } : {}) }
41
+ }
42
+ return null
43
+ }
44
+
45
+ function reseed(m: LinkValue | null | undefined) {
46
+ const v = m ?? null
47
+ if (v) currentType.value = v.type
48
+ label.value = v?.label ?? ''
49
+ url.value = v?.type === 'external' ? v.url : ''
50
+ email.value = v?.type === 'email' ? v.email : ''
51
+ tel.value = v?.type === 'tel' ? v.tel : ''
52
+ collection.value = v?.type === 'internal' ? v.collection : null
53
+ recordId.value = v?.type === 'internal' ? v.id : null
54
+ hash.value = v?.type === 'internal' ? (v.hash ?? '') : ''
55
+ }
56
+
57
+ // Non-immediate so setup never reassigns an equivalent model (no spurious mount emit).
58
+ watch([currentType, label, url, email, tel, collection, recordId, hash], () => { model.value = build() })
59
+
60
+ // Echo-guarded reseed via the shared primitive: drafts follow an external model change, but the
61
+ // build() write above is recognised as self-originated (build() === model) so it never loops.
62
+ useEchoGuard(model, () => build(), reseed)
63
+
64
+ const typeModel = computed<LinkType>({
65
+ get: () => currentType.value,
66
+ set: (v) => { if (v) currentType.value = v as LinkType },
67
+ })
68
+
69
+ return { currentType, label, url, email, tel, collection, recordId, hash, typeModel }
70
+ }
@@ -0,0 +1,84 @@
1
+ import { ref, computed, watch, onUnmounted } from 'vue'
2
+ import type { Ref } from 'vue'
3
+ import type { FieldOption } from '../utils/field-component'
4
+
5
+ export function useRecordOptions(
6
+ collection: Ref<string>,
7
+ ids: Ref<number[]>,
8
+ locale: Ref<string>,
9
+ labelField?: Ref<string | undefined>,
10
+ ) {
11
+ const cache = ref<Map<number, string>>(new Map())
12
+ const options = ref<FieldOption[]>([])
13
+ const loading = ref(false)
14
+
15
+ const selected = computed<FieldOption[]>(() =>
16
+ ids.value.map((value) => ({ value, label: cache.value.get(value) ?? `#${value}` })),
17
+ )
18
+
19
+ function cacheOptions(opts: FieldOption[]) {
20
+ const next = new Map(cache.value)
21
+ for (const o of opts) next.set(o.value, o.label)
22
+ cache.value = next
23
+ }
24
+
25
+ // The endpoint returns { id, label }; map it to the combobox's { value, label }.
26
+ async function fetchOptions(query: Record<string, unknown>): Promise<FieldOption[]> {
27
+ const res = await $fetch<{ data: { id: number; label: string }[] }>(`/api/${collection.value}/options`, {
28
+ query: { label: labelField?.value, locale: locale.value, ...query },
29
+ })
30
+ return res.data.map((r) => ({ value: r.id, label: r.label }))
31
+ }
32
+
33
+ // Labels are per-collection; drop stale options/cache on switch (runs before the resolve below).
34
+ watch(collection, () => {
35
+ cache.value = new Map()
36
+ options.value = []
37
+ })
38
+
39
+ // Resolve labels for any current ids we don't yet have cached. Depends on collection too, so it
40
+ // (re)resolves once the collection becomes available (a v-model prop can arrive after setup) and
41
+ // after a switch — not only when ids change.
42
+ watch([ids, collection], async () => {
43
+ const missing = ids.value.filter((value) => !cache.value.has(value))
44
+ const forCollection = collection.value
45
+ if (!missing.length || !forCollection) return
46
+ try {
47
+ const data = await fetchOptions({ ids: missing.join(',') })
48
+ if (collection.value === forCollection) cacheOptions(data) // discard if the collection switched mid-fetch
49
+ } catch {
50
+ // leave the unresolved ids as `#id` placeholders rather than throwing
51
+ }
52
+ }, { immediate: true })
53
+
54
+ let timer: ReturnType<typeof setTimeout> | undefined
55
+ let searchSeq = 0
56
+
57
+ function onSearch(term: string) {
58
+ if (timer) clearTimeout(timer)
59
+ // No collection → nothing to list. Bump the token so any in-flight fetch can't repopulate.
60
+ if (!collection.value) { ++searchSeq; options.value = []; loading.value = false; return }
61
+ timer = setTimeout(async () => {
62
+ const seq = ++searchSeq
63
+ const forCollection = collection.value
64
+ loading.value = true
65
+ try {
66
+ // An empty term lists the first page (records appear on focus, no typing needed).
67
+ const data = await fetchOptions(term.trim() ? { search: term } : {})
68
+ // ignore if a newer search superseded this one, or the collection switched mid-fetch
69
+ if (seq === searchSeq && collection.value === forCollection) {
70
+ options.value = data
71
+ cacheOptions(data)
72
+ }
73
+ } catch {
74
+ if (seq === searchSeq) options.value = [] // failed search clears rather than showing stale matches
75
+ } finally {
76
+ if (seq === searchSeq) loading.value = false
77
+ }
78
+ }, 250)
79
+ }
80
+
81
+ onUnmounted(() => { if (timer) clearTimeout(timer) })
82
+
83
+ return { options, selected, loading, onSearch }
84
+ }
@@ -0,0 +1,99 @@
1
+ import { ref } from 'vue'
2
+ import type { Ref } from 'vue'
3
+ import type { FieldDef } from '../../../core/server/utils/defineCollection'
4
+ import { reorder } from '../utils/reorder'
5
+ import { useEchoGuard } from './useEchoGuard'
6
+
7
+ export interface UseRepeater {
8
+ rows: Ref<Record<string, unknown>[]>
9
+ keys: Ref<number[]>
10
+ addRow: () => void
11
+ removeRow: (i: number) => void
12
+ move: (from: number, to: number) => void
13
+ setCell: (i: number, key: string, val: unknown) => void
14
+ insertRow: (at: number) => void
15
+ duplicateRow: (i: number) => void
16
+ }
17
+
18
+ export function useRepeater(
19
+ model: Ref<Record<string, unknown>[] | null | undefined>,
20
+ subFields: Ref<Record<string, FieldDef>>,
21
+ ): UseRepeater {
22
+ let seq = 0
23
+
24
+ function cloneRows(v: unknown): Record<string, unknown>[] {
25
+ if (!Array.isArray(v)) return []
26
+ return (v as Record<string, unknown>[]).map(r => ({ ...r }))
27
+ }
28
+
29
+ const rows = ref<Record<string, unknown>[]>(cloneRows(model.value))
30
+ const keys = ref<number[]>(rows.value.map(() => seq++))
31
+
32
+ function emit(): void {
33
+ model.value = rows.value.map(r => ({ ...r }))
34
+ }
35
+
36
+ function reseed(v: Record<string, unknown>[] | null | undefined): void {
37
+ rows.value = cloneRows(v)
38
+ keys.value = rows.value.map(() => seq++)
39
+ }
40
+
41
+ // Echo-guard via the shared primitive: skip our own emit, reseed on a genuine external change. Compares
42
+ // against the CURRENT rows (not a one-shot token) so an undo→redo back to a prior value still reseeds.
43
+ useEchoGuard(model, () => rows.value, reseed, [])
44
+
45
+ function blankRow(): Record<string, unknown> {
46
+ return Object.fromEntries(
47
+ Object.keys(subFields.value).map(k => [k, subFields.value[k]!.default ?? null]),
48
+ )
49
+ }
50
+
51
+ function addRow(): void {
52
+ rows.value = [...rows.value, blankRow()]
53
+ keys.value = [...keys.value, seq++]
54
+ emit()
55
+ }
56
+
57
+ function insertRow(at: number): void {
58
+ const clamped = Math.max(0, Math.min(at, rows.value.length))
59
+ const newRows = [...rows.value]
60
+ const newKeys = [...keys.value]
61
+ newRows.splice(clamped, 0, blankRow())
62
+ newKeys.splice(clamped, 0, seq++)
63
+ rows.value = newRows
64
+ keys.value = newKeys
65
+ emit()
66
+ }
67
+
68
+ function duplicateRow(i: number): void {
69
+ if (i < 0 || i >= rows.value.length) return
70
+ const clone = JSON.parse(JSON.stringify(rows.value[i])) as Record<string, unknown>
71
+ const newRows = [...rows.value]
72
+ const newKeys = [...keys.value]
73
+ newRows.splice(i + 1, 0, clone)
74
+ newKeys.splice(i + 1, 0, seq++)
75
+ rows.value = newRows
76
+ keys.value = newKeys
77
+ emit()
78
+ }
79
+
80
+ function removeRow(i: number): void {
81
+ rows.value = rows.value.filter((_, idx) => idx !== i)
82
+ keys.value = keys.value.filter((_, idx) => idx !== i)
83
+ emit()
84
+ }
85
+
86
+ function move(from: number, to: number): void {
87
+ if (from === to || from < 0 || to < 0 || from >= rows.value.length || to >= rows.value.length) return
88
+ rows.value = reorder(rows.value, from, to)
89
+ keys.value = reorder(keys.value, from, to)
90
+ emit()
91
+ }
92
+
93
+ function setCell(i: number, key: string, val: unknown): void {
94
+ rows.value = rows.value.map((r, idx) => idx === i ? { ...r, [key]: val } : r)
95
+ emit()
96
+ }
97
+
98
+ return { rows, keys, addRow, removeRow, move, setCell, insertRow, duplicateRow }
99
+ }
@@ -0,0 +1,32 @@
1
+ import { en } from '../i18n/en'
2
+ import { de } from '../i18n/de'
3
+
4
+ export type Catalog = Record<string, string>
5
+
6
+ /** Admin-UI languages that ship a catalog. The first is the source + fallback language. */
7
+ export const ADMIN_LANGS = ['en', 'de'] as const
8
+ export type AdminLang = (typeof ADMIN_LANGS)[number]
9
+
10
+ const catalogs: Record<string, Catalog> = { en, de }
11
+
12
+ /** Replace `{name}` placeholders from `params`; an absent param leaves its placeholder untouched. */
13
+ export function interpolate(template: string, params?: Record<string, unknown>): string {
14
+ if (!params) return template
15
+ return template.replace(/\{(\w+)\}/g, (m, k) => (params[k] != null ? String(params[k]) : m))
16
+ }
17
+
18
+ /** Resolve `key` in the active catalog, then the fallback (en), then the key itself; interpolate. Pure. */
19
+ export function translate(catalog: Catalog, fallback: Catalog, key: string, params?: Record<string, unknown>): string {
20
+ return interpolate(catalog[key] ?? fallback[key] ?? key, params)
21
+ }
22
+
23
+ /**
24
+ * Admin-UI translation. `t(key, params?)` reads the active admin language (a cookie-backed preference,
25
+ * separate from the content locale the editor edits) and is reactive — switching the language re-renders.
26
+ * Missing keys fall back to English, then to the key, so a partial `de` catalog degrades gracefully.
27
+ */
28
+ export function useT() {
29
+ const lang = useAdminLang()
30
+ const t = (key: string, params?: Record<string, unknown>) => translate(catalogs[lang.value] ?? en, en, key, params)
31
+ return { t, lang }
32
+ }