@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,173 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+
4
+ // Attrs (id, aria-*) must land on the inner <input>, not the suffix wrapper, so the field label's
5
+ // `for` keeps pointing at the control. With no suffix the input still receives them via v-bind="$attrs".
6
+ defineOptions({ inheritAttrs: false })
7
+
8
+ withDefaults(
9
+ defineProps<{
10
+ min?: number
11
+ max?: number
12
+ step?: number | 'any'
13
+ placeholder?: string
14
+ disabled?: boolean
15
+ // Display-only suffix (e.g. 'rem'). When set, the input gains a trailing adornment; the emitted
16
+ // model value is still a bare number. When absent, the plain `<input class="ui-number">` renders.
17
+ suffix?: string
18
+ /** Tighter padding for dense chrome (toolbars). Still meets the AA minimum target size. */
19
+ slim?: boolean
20
+ }>(),
21
+ { disabled: false, slim: false },
22
+ )
23
+
24
+ const model = defineModel<number | null>()
25
+
26
+ // Bound with `v-model` rather than :value/@input on purpose: mid-edit text like '1.0' parses to the
27
+ // number 1, and a plain :value binding would write that back over the text, deleting the trailing digit
28
+ // and moving the caret ('1.05' would end up as 15). v-model's directive skips the DOM write while the
29
+ // field is focused and its text still parses to the same number, so typing stays intact.
30
+ // The proxy only maps the empty field to null; v-model already hands over a number for a number input.
31
+ const text = computed<number | string>({
32
+ get: () => model.value ?? '',
33
+ set: (v) => { model.value = v === '' ? null : Number(v) },
34
+ })
35
+
36
+ // A number input can hold editing text the browser can't parse ('-', '1e'); its DOM value is then ''
37
+ // and the model already null, so a save would silently drop what the user still sees in the field.
38
+ // Snap the text back to the model so the field never shows a value that wouldn't be saved.
39
+ // (Assigning `value` clears the unparsable editing text even though the raw value is already ''.)
40
+ function snapBack(el: HTMLInputElement) {
41
+ if (el.validity?.badInput) el.value = model.value == null ? '' : String(model.value)
42
+ }
43
+ function onBlur(e: Event) {
44
+ snapBack(e.target as HTMLInputElement)
45
+ }
46
+ // Enter fires the ancestor form's implicit submission WITHOUT a preceding blur, so the blur guard
47
+ // can't run — with the form's native validation off, a null would be saved while unparsable text
48
+ // still shows. Suppress that submit and snap back so the mismatch is corrected and visible instead.
49
+ function onKeydown(e: KeyboardEvent) {
50
+ if (e.key !== 'Enter' || e.isComposing) return
51
+ const el = e.target as HTMLInputElement
52
+ if (el.validity?.badInput) { e.preventDefault(); snapBack(el) }
53
+ }
54
+ </script>
55
+
56
+ <template>
57
+ <div v-if="suffix" class="ui-number-wrap" :class="{ 'ui-number-wrap--slim': slim }" :data-disabled="disabled || undefined">
58
+ <input
59
+ v-model="text"
60
+ type="number"
61
+ :min="min"
62
+ :max="max"
63
+ :step="step"
64
+ :placeholder="placeholder"
65
+ :disabled="disabled"
66
+ class="ui-number-wrap__input"
67
+ v-bind="$attrs"
68
+ @blur="onBlur"
69
+ @keydown="onKeydown"
70
+ >
71
+ <span class="ui-number-wrap__suffix" aria-hidden="true">{{ suffix }}</span>
72
+ </div>
73
+ <input
74
+ v-else
75
+ v-model="text"
76
+ type="number"
77
+ :min="min"
78
+ :max="max"
79
+ :step="step"
80
+ :placeholder="placeholder"
81
+ :disabled="disabled"
82
+ class="ui-number"
83
+ :class="{ 'ui-number--slim': slim }"
84
+ v-bind="$attrs"
85
+ @blur="onBlur"
86
+ @keydown="onKeydown"
87
+ >
88
+ </template>
89
+
90
+ <style lang="scss">
91
+ @use '../../assets/scss/mixins' as *;
92
+
93
+ .ui-number {
94
+ @include input-base;
95
+ width: 100%;
96
+
97
+ &::placeholder {
98
+ color: var(--color-text-muted);
99
+ }
100
+ &--slim {
101
+ @include input-slim;
102
+ }
103
+ }
104
+
105
+ // Suffix variant (opt-in via the `suffix` prop). The border/radius live on the wrapper; the inner input
106
+ // is borderless + transparent so it reads as one control, mirroring the slug prefix adornment.
107
+ .ui-number-wrap {
108
+ display: flex;
109
+ align-items: stretch;
110
+ width: 100%;
111
+ min-width: 0;
112
+ border: 1px solid var(--color-control-border, var(--color-border));
113
+ border-radius: var(--radius-md);
114
+ background: var(--color-surface);
115
+ overflow: hidden; // clip the square-cornered suffix background to the wrapper's rounded border
116
+
117
+ &:focus-within {
118
+ outline: 2px solid var(--color-focus);
119
+ outline-offset: -2px;
120
+ }
121
+ &[data-disabled] {
122
+ opacity: 0.6;
123
+ cursor: not-allowed;
124
+ }
125
+ // input-base flags the invalid state on the input's own border; here the border lives on the wrapper
126
+ // while the inner input is borderless, so mirror it so a unit field shows the same red invalid border.
127
+ &:has(.ui-number-wrap__input[aria-invalid='true']) {
128
+ border-color: var(--color-danger);
129
+ }
130
+
131
+ &__input {
132
+ flex: 1 1 auto;
133
+ min-width: 0;
134
+ padding: var(--space-2) var(--space-3);
135
+ border: 0;
136
+ background: transparent;
137
+ color: var(--color-text);
138
+ font-size: var(--text-base);
139
+
140
+ &:focus-visible {
141
+ outline: none;
142
+ }
143
+ &::placeholder {
144
+ color: var(--color-text-muted);
145
+ }
146
+ &:disabled {
147
+ cursor: not-allowed;
148
+ }
149
+ }
150
+
151
+ &__suffix {
152
+ display: inline-flex;
153
+ align-items: center;
154
+ padding-inline: var(--space-3);
155
+ background: var(--color-surface-2);
156
+ color: var(--color-text-muted);
157
+ border-left: 1px solid var(--color-control-border, var(--color-border));
158
+ font-size: var(--text-sm);
159
+ white-space: nowrap;
160
+ }
161
+ }
162
+
163
+ // Slim adornment variant: the wrapper keeps its border/radius, only the inner box tightens. Written as a
164
+ // separate rule (not nested) so `.ui-number-wrap--slim .ui-number-wrap__input` outranks the base element rule.
165
+ .ui-number-wrap--slim {
166
+ .ui-number-wrap__input {
167
+ @include input-slim;
168
+ }
169
+ .ui-number-wrap__suffix {
170
+ padding-inline: var(--space-2);
171
+ }
172
+ }
173
+ </style>
@@ -0,0 +1,119 @@
1
+ <script setup lang="ts">
2
+ import { watch, watchEffect } from 'vue'
3
+ import { useEditor, EditorContent } from '@tiptap/vue-3'
4
+ import { StarterKit } from '@tiptap/starter-kit'
5
+ import { Highlight } from '@tiptap/extension-highlight'
6
+ import { Subscript } from '@tiptap/extension-subscript'
7
+ import { Superscript } from '@tiptap/extension-superscript'
8
+ import { TextAlign } from '@tiptap/extension-text-align'
9
+ import RichtextToolbar from './RichtextToolbar.vue'
10
+ import { RichtextSpanClass, RichtextBlockClass } from '../../utils/richtext-preserve-class'
11
+
12
+ const props = withDefaults(
13
+ defineProps<{
14
+ disabled?: boolean
15
+ locale?: string
16
+ // a11y: the editing surface is a contenteditable with no native label; the field name + error/state
17
+ // are set on the ProseMirror DOM node (editor.view.dom) so AT announces a named multiline textbox.
18
+ ariaLabel?: string
19
+ describedby?: string
20
+ invalid?: boolean
21
+ required?: boolean
22
+ }>(),
23
+ { disabled: false },
24
+ )
25
+ const model = defineModel<string | null>()
26
+
27
+ // StarterKit (v3) already bundles Link + Underline; only the rest are added here.
28
+ const editor = useEditor({
29
+ content: model.value ?? '',
30
+ editable: !props.disabled,
31
+ // (no `immediatelyRender` — not in tiptap v3's EditorOptions type; the admin editor is SPA-only so the
32
+ // browser default applies.)
33
+ extensions: [
34
+ StarterKit.configure({
35
+ heading: { levels: [1, 2, 3, 4, 5, 6] },
36
+ link: { openOnClick: false },
37
+ }),
38
+ Highlight,
39
+ Subscript,
40
+ Superscript,
41
+ TextAlign.configure({ types: ['heading', 'paragraph'] }),
42
+ RichtextSpanClass, RichtextBlockClass, // preserve consumer presentational classes on round-trip
43
+ ],
44
+ onUpdate: ({ editor }) => {
45
+ model.value = editor.isEmpty ? '' : editor.getHTML()
46
+ },
47
+ })
48
+
49
+ watch(model, (value) => {
50
+ const e = editor.value
51
+ if (!e) return
52
+ const next = value ?? ''
53
+ const current = e.isEmpty ? '' : e.getHTML()
54
+ // `emitUpdate: false`: this value came FROM the model, so re-emitting it would reach the edit form as
55
+ // a user edit — clearing the redo stack and turning a restored null back into ''.
56
+ if (next !== current) e.commands.setContent(next, { emitUpdate: false })
57
+ })
58
+
59
+ watch(() => props.disabled, (d) => editor.value?.setEditable(!d))
60
+
61
+ // Apply the accessible name + textbox semantics directly to the ProseMirror surface, reactively (the
62
+ // error-driven describedby/invalid arrive after mount). `editor.view.dom` is the contenteditable element.
63
+ function setAttr(el: HTMLElement, name: string, value?: string) {
64
+ if (value) el.setAttribute(name, value)
65
+ else el.removeAttribute(name)
66
+ }
67
+ watchEffect(() => {
68
+ const dom = editor.value?.view?.dom as HTMLElement | undefined
69
+ if (!dom) return
70
+ dom.setAttribute('role', 'textbox')
71
+ dom.setAttribute('aria-multiline', 'true')
72
+ setAttr(dom, 'aria-label', props.ariaLabel)
73
+ setAttr(dom, 'aria-describedby', props.describedby)
74
+ setAttr(dom, 'aria-invalid', props.invalid ? 'true' : undefined)
75
+ setAttr(dom, 'aria-required', props.required ? 'true' : undefined)
76
+ })
77
+ </script>
78
+
79
+ <template>
80
+ <div class="ui-richtext">
81
+ <RichtextToolbar :editor="editor" :locale="locale" :disabled="disabled" />
82
+ <EditorContent :editor="editor" class="ui-richtext__content" />
83
+ </div>
84
+ </template>
85
+
86
+ <style lang="scss">
87
+ .ui-richtext {
88
+ border: 1px solid var(--color-control-border, var(--color-border));
89
+ border-radius: var(--radius-md);
90
+ background: var(--color-surface);
91
+
92
+ &__content {
93
+ .tiptap {
94
+ padding: var(--space-3);
95
+ min-height: 8rem;
96
+ outline: none;
97
+
98
+ :where(p, ul, ol, blockquote, pre, h1, h2, h3, h4, h5, h6) { margin: 0 0 var(--space-3); }
99
+ :where(p, ul, ol, blockquote, pre, h1, h2, h3, h4, h5, h6):last-child { margin-bottom: 0; }
100
+ ul, ol { padding-inline-start: var(--space-5); }
101
+ blockquote {
102
+ border-inline-start: 3px solid var(--color-border);
103
+ padding-inline-start: var(--space-3);
104
+ color: var(--color-text-muted);
105
+ }
106
+ pre {
107
+ background: var(--color-bg);
108
+ padding: var(--space-3);
109
+ border-radius: var(--radius-sm);
110
+ overflow-x: auto;
111
+ }
112
+ code { font-family: monospace; }
113
+ a { color: var(--color-primary); }
114
+ mark { background: var(--color-highlight); color: var(--color-on-highlight); }
115
+ hr { border: 0; border-top: 1px solid var(--color-border); margin: var(--space-4) 0; }
116
+ }
117
+ }
118
+ }
119
+ </style>
@@ -0,0 +1,253 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch } from 'vue'
3
+ import type { Editor } from '@tiptap/vue-3'
4
+ import UiButton from './Button.vue'
5
+ import UiIcon from './Icon.vue'
6
+ import UiSelect from './Select.vue'
7
+ import UiTextInput from './TextInput.vue'
8
+ import LinkInternalPicker from '../field/LinkInternalPicker.vue'
9
+ import type { IconName } from '../../utils/icons'
10
+ import { richtextLinkHref, parseRichtextLinkHref } from '../../../../fields/app/utils/richtext-links'
11
+
12
+ const props = defineProps<{ editor: Editor | undefined; locale?: string; disabled?: boolean }>()
13
+
14
+ interface Item {
15
+ name: string
16
+ label: string
17
+ icon: IconName
18
+ run: (e: Editor) => void
19
+ active?: (e: Editor) => boolean
20
+ disabled?: (e: Editor) => boolean
21
+ }
22
+
23
+ const { t } = useT()
24
+
25
+ const MARKS = computed((): Item[] => [
26
+ { name: 'bold', label: t('richtext.bold'), icon: 'bold', run: (e) => e.chain().focus().toggleBold().run(), active: (e) => e.isActive('bold') },
27
+ { name: 'italic', label: t('richtext.italic'), icon: 'italic', run: (e) => e.chain().focus().toggleItalic().run(), active: (e) => e.isActive('italic') },
28
+ { name: 'underline', label: t('richtext.underline'), icon: 'underline', run: (e) => e.chain().focus().toggleUnderline().run(), active: (e) => e.isActive('underline') },
29
+ { name: 'strike', label: t('richtext.strikethrough'), icon: 'strikethrough', run: (e) => e.chain().focus().toggleStrike().run(), active: (e) => e.isActive('strike') },
30
+ { name: 'code', label: t('richtext.inlineCode'), icon: 'code', run: (e) => e.chain().focus().toggleCode().run(), active: (e) => e.isActive('code') },
31
+ { name: 'highlight', label: t('richtext.highlight'), icon: 'highlighter', run: (e) => e.chain().focus().toggleHighlight().run(), active: (e) => e.isActive('highlight') },
32
+ { name: 'subscript', label: t('richtext.subscript'), icon: 'subscript', run: (e) => e.chain().focus().toggleSubscript().run(), active: (e) => e.isActive('subscript') },
33
+ { name: 'superscript', label: t('richtext.superscript'), icon: 'superscript', run: (e) => e.chain().focus().toggleSuperscript().run(), active: (e) => e.isActive('superscript') },
34
+ ])
35
+ const BLOCKS = computed((): Item[] => [
36
+ { name: 'bulletList', label: t('richtext.bulletList'), icon: 'list', run: (e) => e.chain().focus().toggleBulletList().run(), active: (e) => e.isActive('bulletList') },
37
+ { name: 'orderedList', label: t('richtext.orderedList'), icon: 'list-ordered', run: (e) => e.chain().focus().toggleOrderedList().run(), active: (e) => e.isActive('orderedList') },
38
+ { name: 'blockquote', label: t('richtext.blockquote'), icon: 'quote', run: (e) => e.chain().focus().toggleBlockquote().run(), active: (e) => e.isActive('blockquote') },
39
+ { name: 'codeBlock', label: t('richtext.codeBlock'), icon: 'square-code', run: (e) => e.chain().focus().toggleCodeBlock().run(), active: (e) => e.isActive('codeBlock') },
40
+ { name: 'horizontalRule', label: t('richtext.horizontalRule'), icon: 'minus', run: (e) => e.chain().focus().setHorizontalRule().run() },
41
+ ])
42
+ const ALIGN = computed((): Item[] => [
43
+ { name: 'left', label: t('richtext.alignLeft'), icon: 'align-left', run: (e) => e.chain().focus().setTextAlign('left').run(), active: (e) => e.isActive({ textAlign: 'left' }) },
44
+ { name: 'center', label: t('richtext.alignCenter'), icon: 'align-center', run: (e) => e.chain().focus().setTextAlign('center').run(), active: (e) => e.isActive({ textAlign: 'center' }) },
45
+ { name: 'right', label: t('richtext.alignRight'), icon: 'align-right', run: (e) => e.chain().focus().setTextAlign('right').run(), active: (e) => e.isActive({ textAlign: 'right' }) },
46
+ { name: 'justify', label: t('richtext.justify'), icon: 'align-justify', run: (e) => e.chain().focus().setTextAlign('justify').run(), active: (e) => e.isActive({ textAlign: 'justify' }) },
47
+ ])
48
+ const EXTRA = computed((): Item[] => [
49
+ { name: 'clear', label: t('richtext.clearFormatting'), icon: 'eraser', run: (e) => e.chain().focus().unsetAllMarks().clearNodes().run() },
50
+ { name: 'undo', label: t('richtext.undo'), icon: 'undo', run: (e) => e.chain().focus().undo().run(), disabled: (e) => !e.can().undo() },
51
+ { name: 'redo', label: t('richtext.redo'), icon: 'redo', run: (e) => e.chain().focus().redo().run(), disabled: (e) => !e.can().redo() },
52
+ ])
53
+
54
+ const BLOCK_OPTIONS = computed(() => [
55
+ { label: t('richtext.paragraph'), value: 'p' },
56
+ { label: t('richtext.heading1'), value: 'h1' }, { label: t('richtext.heading2'), value: 'h2' }, { label: t('richtext.heading3'), value: 'h3' },
57
+ { label: t('richtext.heading4'), value: 'h4' }, { label: t('richtext.heading5'), value: 'h5' }, { label: t('richtext.heading6'), value: 'h6' },
58
+ ])
59
+
60
+ // Re-render the toolbar on every editor transaction so active/disabled states stay live.
61
+ const tick = ref(0)
62
+ watch(() => props.editor, (editor, _old, onCleanup) => {
63
+ if (!editor) return
64
+ const bump = () => { tick.value++ }
65
+ editor.on('transaction', bump)
66
+ onCleanup(() => { editor.off('transaction', bump) })
67
+ }, { immediate: true })
68
+
69
+ const blockValue = computed(() => {
70
+ void tick.value
71
+ const e = props.editor
72
+ if (!e) return 'p'
73
+ for (let l = 1; l <= 6; l++) if (e.isActive('heading', { level: l })) return `h${l}`
74
+ return 'p'
75
+ })
76
+ function onBlock(v: string | null | undefined) {
77
+ const e = props.editor
78
+ if (!e || !v) return
79
+ if (v === 'p') e.chain().focus().setParagraph().run()
80
+ else e.chain().focus().toggleHeading({ level: Number(v[1]) as 1 | 2 | 3 | 4 | 5 | 6 }).run()
81
+ }
82
+
83
+ const linkOpen = ref(false)
84
+ const linkUrl = ref('')
85
+ function toggleLink() {
86
+ const e = props.editor!
87
+ if (e.isActive('link')) { e.chain().focus().unsetLink().run(); return }
88
+ linkInternalOpen.value = false
89
+ linkUrl.value = (e.getAttributes('link').href as string) ?? ''
90
+ linkOpen.value = true
91
+ }
92
+ function applyLink() {
93
+ const e = props.editor!
94
+ const href = linkUrl.value.trim()
95
+ if (href) e.chain().focus().extendMarkRange('link').setLink({ href }).run()
96
+ linkOpen.value = false
97
+ linkUrl.value = ''
98
+ }
99
+
100
+ // Internal links: pick a record, store it as a `kestrel:<collection>:<id>` marker on a normal link
101
+ // mark (resolved to the real localized path at read time / in the preview).
102
+ const linkInternalOpen = ref(false)
103
+ const pickCollection = ref<string | null>(null)
104
+ const pickRecordId = ref<number | null>(null)
105
+ function toggleInternalLink() {
106
+ linkOpen.value = false
107
+ const opening = !linkInternalOpen.value
108
+ linkInternalOpen.value = opening
109
+ if (!opening) return
110
+ // Prefill from the link under the cursor: an existing internal marker re-selects its record (the
111
+ // picker loads + highlights it); an external/absent link opens the picker blank.
112
+ const parsed = parseRichtextLinkHref(props.editor!.getAttributes('link').href as string | undefined)
113
+ pickCollection.value = parsed?.collection ?? null
114
+ pickRecordId.value = parsed?.id ?? null
115
+ }
116
+ function applyInternalLink() {
117
+ const e = props.editor!
118
+ if (pickCollection.value && pickRecordId.value != null) {
119
+ e.chain().focus().extendMarkRange('link').setLink({ href: richtextLinkHref(pickCollection.value, pickRecordId.value) }).run()
120
+ }
121
+ linkInternalOpen.value = false
122
+ pickCollection.value = null
123
+ pickRecordId.value = null
124
+ }
125
+ </script>
126
+
127
+ <template>
128
+ <div v-if="editor" class="ui-rt-toolbar" :data-rev="tick">
129
+ <UiSelect
130
+ class="ui-rt-toolbar__block"
131
+ :model-value="blockValue"
132
+ :options="BLOCK_OPTIONS"
133
+ :aria-label="t('richtext.blockType')"
134
+ :disabled="disabled"
135
+ @update:model-value="onBlock"
136
+ />
137
+
138
+ <div class="ui-rt-toolbar__group">
139
+ <UiButton
140
+ v-for="item in MARKS" :key="item.name"
141
+ type="button" size="sm" variant="ghost"
142
+ :class="{ 'rt-on': item.active!(editor) }"
143
+ :aria-label="item.label"
144
+ :aria-pressed="item.active!(editor) ? 'true' : 'false'"
145
+ :disabled="disabled"
146
+ @click="item.run(editor)"
147
+ ><UiIcon :name="item.icon" :size="16" /></UiButton>
148
+ </div>
149
+
150
+ <div class="ui-rt-toolbar__group">
151
+ <UiButton
152
+ type="button" size="sm" variant="ghost"
153
+ :class="{ 'rt-on': editor.isActive('link') }"
154
+ :aria-label="t('richtext.link')"
155
+ :aria-pressed="editor.isActive('link') ? 'true' : 'false'"
156
+ :disabled="disabled"
157
+ @click="toggleLink"
158
+ ><UiIcon name="link" :size="16" /></UiButton>
159
+ <UiButton
160
+ type="button" size="sm" variant="ghost"
161
+ :class="{ 'rt-on': linkInternalOpen }"
162
+ :aria-label="t('richtext.linkInternal')"
163
+ :aria-pressed="linkInternalOpen ? 'true' : 'false'"
164
+ :disabled="disabled"
165
+ @click="toggleInternalLink"
166
+ ><UiIcon name="link-2" :size="16" /></UiButton>
167
+ </div>
168
+
169
+ <div class="ui-rt-toolbar__group">
170
+ <UiButton
171
+ v-for="item in BLOCKS" :key="item.name"
172
+ type="button" size="sm" variant="ghost"
173
+ :class="{ 'rt-on': item.active && item.active(editor) }"
174
+ :aria-label="item.label"
175
+ :aria-pressed="item.active ? (item.active(editor) ? 'true' : 'false') : undefined"
176
+ :disabled="disabled"
177
+ @click="item.run(editor)"
178
+ ><UiIcon :name="item.icon" :size="16" /></UiButton>
179
+ </div>
180
+
181
+ <div class="ui-rt-toolbar__group">
182
+ <UiButton
183
+ v-for="item in ALIGN" :key="item.name"
184
+ type="button" size="sm" variant="ghost"
185
+ :class="{ 'rt-on': item.active!(editor) }"
186
+ :aria-label="item.label"
187
+ :aria-pressed="item.active!(editor) ? 'true' : 'false'"
188
+ :disabled="disabled"
189
+ @click="item.run(editor)"
190
+ ><UiIcon :name="item.icon" :size="16" /></UiButton>
191
+ </div>
192
+
193
+ <div class="ui-rt-toolbar__group">
194
+ <UiButton
195
+ v-for="item in EXTRA" :key="item.name"
196
+ type="button" size="sm" variant="ghost"
197
+ :aria-label="item.label"
198
+ :disabled="disabled || (item.disabled ? item.disabled(editor) : false)"
199
+ @click="item.run(editor)"
200
+ ><UiIcon :name="item.icon" :size="16" /></UiButton>
201
+ </div>
202
+
203
+ <div v-if="linkOpen" class="ui-rt-toolbar__link">
204
+ <UiTextInput v-model="linkUrl" type="url" :placeholder="t('richtext.urlPlaceholder')" :aria-label="t('richtext.linkUrl')" @keydown.enter.prevent="applyLink" />
205
+ <UiButton type="button" size="sm" variant="primary" @click="applyLink">{{ t('richtext.apply') }}</UiButton>
206
+ </div>
207
+
208
+ <div v-if="linkInternalOpen" class="ui-rt-toolbar__link">
209
+ <LinkInternalPicker v-model:collection="pickCollection" v-model:record-id="pickRecordId" :locale="locale ?? 'en'" />
210
+ <UiButton type="button" size="sm" variant="primary" :disabled="pickRecordId == null" @click="applyInternalLink">{{ t('richtext.apply') }}</UiButton>
211
+ </div>
212
+ </div>
213
+ </template>
214
+
215
+ <style lang="scss">
216
+ .ui-rt-toolbar {
217
+ display: flex;
218
+ flex-wrap: wrap;
219
+ align-items: center;
220
+ gap: var(--space-2);
221
+ padding: var(--space-2);
222
+ border-bottom: 1px solid var(--color-border);
223
+
224
+ &__group {
225
+ display: inline-flex;
226
+ gap: var(--space-1);
227
+ }
228
+ &__block {
229
+ width: auto;
230
+ min-width: 8rem;
231
+ }
232
+ &__link {
233
+ display: flex;
234
+ gap: var(--space-2);
235
+ flex-basis: 100%;
236
+ align-items: center;
237
+ }
238
+
239
+ // Active toggle: a calm neutral fill with the accent carried only by the icon — matching the
240
+ // calmed active-nav treatment, instead of a solid-indigo button per active mark.
241
+ &__group .ui-button.rt-on {
242
+ background: var(--color-active, var(--color-surface-2));
243
+ color: var(--color-text);
244
+
245
+ &:hover:not(:disabled) {
246
+ background: var(--color-active, var(--color-surface-2));
247
+ }
248
+ .ui-icon {
249
+ color: var(--color-primary);
250
+ }
251
+ }
252
+ }
253
+ </style>
@@ -0,0 +1,51 @@
1
+ <script setup lang="ts">
2
+ import UiIcon from './Icon.vue'
3
+
4
+ defineOptions({ inheritAttrs: false })
5
+
6
+ withDefaults(
7
+ defineProps<{
8
+ options: { label: string; value: string }[]
9
+ placeholder?: string
10
+ disabled?: boolean
11
+ }>(),
12
+ { disabled: false },
13
+ )
14
+
15
+ const model = defineModel<string | null>()
16
+ </script>
17
+
18
+ <template>
19
+ <span class="ui-select">
20
+ <select v-model="model" :disabled="disabled" class="ui-select__field" v-bind="$attrs">
21
+ <option v-if="placeholder" value="" disabled>{{ placeholder }}</option>
22
+ <option v-for="o in options" :key="o.value" :value="o.value">{{ o.label }}</option>
23
+ </select>
24
+ <UiIcon name="chevron-down" :size="14" class="ui-select__icon" />
25
+ </span>
26
+ </template>
27
+
28
+ <style lang="scss">
29
+ @use '../../assets/scss/mixins' as *;
30
+
31
+ .ui-select {
32
+ position: relative;
33
+ display: block;
34
+ width: 100%;
35
+ }
36
+ .ui-select__field {
37
+ @include input-base;
38
+ width: 100%;
39
+ appearance: none;
40
+ // leave room for the overlaid chevron so long values don't run under it
41
+ padding-right: calc(var(--space-3) + 1.25rem);
42
+ }
43
+ .ui-select__icon {
44
+ position: absolute;
45
+ top: 50%;
46
+ right: var(--space-3);
47
+ transform: translateY(-50%);
48
+ pointer-events: none; // clicks fall through to the native select
49
+ color: var(--color-text-muted);
50
+ }
51
+ </style>