@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,50 @@
1
+ // Focus ring is INSET (negative offset, drawn within the border-box) so a scrolling/overflow ancestor
2
+ // can never clip it at an edge — and so the ring looks identical across every component, whether it
3
+ // @includes this mixin or mirrors it inline.
4
+ @mixin focus-ring {
5
+ &:focus-visible {
6
+ outline: 2px solid var(--color-focus);
7
+ outline-offset: -2px;
8
+ }
9
+ }
10
+
11
+ @mixin sr-only {
12
+ position: absolute;
13
+ width: 1px;
14
+ height: 1px;
15
+ padding: 0;
16
+ margin: -1px;
17
+ overflow: hidden;
18
+ clip: rect(0, 0, 0, 0);
19
+ white-space: nowrap;
20
+ border: 0;
21
+ }
22
+
23
+ // Shared chrome for text-like form controls (input/textarea/select/time). Per-component extras
24
+ // (width, ::placeholder, resize, …) stay in the component.
25
+ @mixin input-base {
26
+ @include focus-ring;
27
+ padding: var(--space-2) var(--space-3);
28
+ // Interactive control boundary needs 3:1 (WCAG 1.4.11). Admin themes define --color-control-border;
29
+ // the public SSG site has no such token, so it falls back to --color-border (unchanged there).
30
+ border: 1px solid var(--color-control-border, var(--color-border));
31
+ border-radius: var(--radius-md);
32
+ background: var(--color-surface);
33
+ color: var(--color-text);
34
+ font-size: var(--text-base);
35
+
36
+ &[aria-invalid='true'] {
37
+ border-color: var(--color-danger);
38
+ }
39
+ &:disabled {
40
+ opacity: 0.6;
41
+ cursor: not-allowed;
42
+ }
43
+ }
44
+
45
+ // Slim variant of `input-base`'s box, for dense chrome (e.g. the preview toolbar's W×H fields). ONLY the
46
+ // padding shrinks: at the unchanged --text-base the control still measures ~29px tall, clearing the 24×24
47
+ // minimum target size (WCAG 2.2 SC 2.5.8, AA). Do not also shrink the font — that would drop under it.
48
+ @mixin input-slim {
49
+ padding: var(--space-1) var(--space-2);
50
+ }
@@ -0,0 +1,65 @@
1
+ *,
2
+ *::before,
3
+ *::after {
4
+ box-sizing: border-box;
5
+ }
6
+
7
+ * {
8
+ margin: 0;
9
+ }
10
+
11
+ html {
12
+ -webkit-text-size-adjust: 100%;
13
+ }
14
+
15
+ body {
16
+ min-height: 100svh;
17
+ line-height: var(--leading-normal);
18
+ -webkit-font-smoothing: antialiased;
19
+ }
20
+
21
+ img,
22
+ picture,
23
+ svg,
24
+ video,
25
+ canvas {
26
+ display: block;
27
+ max-width: 100%;
28
+ }
29
+
30
+ input,
31
+ button,
32
+ textarea,
33
+ select {
34
+ font: inherit;
35
+ color: inherit;
36
+ }
37
+
38
+ button {
39
+ cursor: pointer;
40
+ }
41
+
42
+ button:disabled {
43
+ cursor: not-allowed;
44
+ }
45
+
46
+ p,
47
+ h1,
48
+ h2,
49
+ h3,
50
+ h4,
51
+ h5,
52
+ h6 {
53
+ overflow-wrap: break-word;
54
+ }
55
+
56
+ @media (prefers-reduced-motion: reduce) {
57
+ *,
58
+ *::before,
59
+ *::after {
60
+ animation-duration: 0.01ms !important;
61
+ animation-iteration-count: 1 !important;
62
+ transition-duration: 0.01ms !important;
63
+ scroll-behavior: auto !important;
64
+ }
65
+ }
@@ -0,0 +1,183 @@
1
+ /* Design tokens.
2
+ *
3
+ * `:root` holds the SHARED structural scale (space / type / radius / shadow / z / motion / layout)
4
+ * plus the PUBLIC SSG site's colour defaults — left untouched so the generated site is unaffected.
5
+ *
6
+ * The admin shell opts into a richer, neutral "depth" palette (and dark mode) by setting
7
+ * `data-theme="light|dark"` on <html> (see `useTheme` + the admin layout). Because the attribute
8
+ * lives on <html>, teleported widgets (dialogs, comboboxes) resolve the same tokens.
9
+ */
10
+ :root {
11
+ /* ---- color: public defaults (do not retune — drives the SSG site) ---- */
12
+ --color-bg: #ffffff;
13
+ --color-surface: #ffffff;
14
+ --color-surface-2: #f4f4f5;
15
+ --color-text: #1a1a1a;
16
+ --color-text-muted: #5c5c5c;
17
+ --color-text-subtle: #a1a1aa;
18
+ --color-border: #d4d4d8;
19
+ --color-border-strong: #b4b4bb;
20
+ --color-primary: #2563eb;
21
+ --color-primary-hover: #1d4ed8;
22
+ --color-on-primary: #ffffff;
23
+ --color-primary-solid: #2563eb; /* fill for solid primary controls (keeps AA with on-primary text) */
24
+ --color-danger: #dc2626;
25
+ --color-danger-solid: #dc2626; /* fill for solid danger controls (keeps AA with on-danger text) */
26
+ --color-on-danger: #ffffff;
27
+ --color-success: #16a34a;
28
+ --color-warning: #d97706; /* amber accent for icons/borders (non-text, ≥3:1) */
29
+ --color-warning-text: #b45309; /* darker amber for warning TEXT (≥4.5:1 on surface, WCAG 1.4.3) */
30
+ --color-highlight: #fef08a; /* <mark> background */
31
+ --color-on-highlight: #1a1a1a;
32
+ --color-focus: #2563eb;
33
+ --color-scrim: rgba(0, 0, 0, 0.5);
34
+ --color-rail-bg: #f7f7f8;
35
+ --color-hover: rgba(24, 24, 27, 0.06);
36
+ --color-primary-soft: rgba(37, 99, 235, 0.1);
37
+
38
+ /* space (rem step scale) */
39
+ --space-1: 0.25rem;
40
+ --space-2: 0.5rem;
41
+ --space-3: 0.75rem;
42
+ --space-4: 1rem;
43
+ --space-5: 1.5rem;
44
+ --space-6: 2rem;
45
+ --space-7: 3rem;
46
+ --space-8: 4rem;
47
+
48
+ /* type */
49
+ --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
50
+ --text-xs: 0.75rem;
51
+ --text-sm: 0.875rem;
52
+ --text-base: 1rem;
53
+ --text-lg: 1.125rem;
54
+ --text-xl: 1.5rem;
55
+ --leading-normal: 1.5;
56
+ --weight-normal: 400;
57
+ --weight-medium: 500;
58
+ --weight-bold: 700;
59
+
60
+ /* radius */
61
+ --radius-sm: 4px;
62
+ --radius-md: 8px;
63
+ --radius-lg: 12px;
64
+ --radius-full: 9999px;
65
+
66
+ /* shadow */
67
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
68
+ --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
69
+ --shadow-lg: 0 12px 32px rgba(0, 0, 0, 0.16);
70
+
71
+ /* z-index */
72
+ --z-dropdown: 1000;
73
+ --z-overlay: 1100;
74
+ --z-dialog: 1200;
75
+ --z-toast: 1300;
76
+
77
+ /* motion */
78
+ --motion-fast: 120ms;
79
+ --motion-base: 200ms;
80
+ --ease-standard: cubic-bezier(0.2, 0, 0, 1);
81
+
82
+ /* layout */
83
+ --rail-width: 15rem;
84
+ --rail-width-collapsed: 4rem;
85
+ --content-measure: 72rem;
86
+ }
87
+
88
+ /* ===================================================================== *
89
+ * Admin theme — Light (neutral zinc + calm indigo, soft depth)
90
+ * ===================================================================== */
91
+ :root[data-theme="light"] {
92
+ color-scheme: light;
93
+
94
+ --color-bg: #fafafa; /* tinted app canvas so surfaces lift off it */
95
+ --color-surface: #ffffff; /* cards, panels, inputs-on-card */
96
+ --color-surface-2: #f4f4f5; /* sunken / secondary fills, input wells */
97
+ --color-text: #18181b; /* zinc-900 */
98
+ --color-text-muted: #71717a; /* zinc-500 */
99
+ --color-text-subtle: #a1a1aa; /* zinc-400 — hints, placeholders */
100
+ --color-border: #e4e4e7; /* zinc-200 — hairline (decorative) */
101
+ --color-border-strong: #d4d4d8; /* zinc-300 — dividers */
102
+ --color-control-border: #8e8e96; /* interactive control boundary — ~3.3:1 on surface (WCAG 1.4.11) */
103
+ --color-primary: #4f46e5; /* indigo-600 */
104
+ --color-primary-hover: #4338ca; /* indigo-700 */
105
+ --color-on-primary: #ffffff;
106
+ --color-primary-solid: #4f46e5; /* solid indigo fill (white text = 6.29:1, AA) */
107
+ --color-primary-soft: rgba(79, 70, 229, 0.1);
108
+ --color-danger: #dc2626;
109
+ --color-danger-solid: #dc2626;
110
+ --color-on-danger: #ffffff;
111
+ --color-success: #16a34a;
112
+ --color-warning: #d97706;
113
+ --color-warning-text: #b45309; /* amber-700 — warning text ≈4.9:1 on the light surfaces */
114
+ --color-highlight: #fde68a;
115
+ --color-on-highlight: #18181b;
116
+ --color-focus: #4f46e5;
117
+ --color-link: #1a0dab; /* SERP-style link (SEO Google preview) — high contrast on light */
118
+ --color-scrim: rgba(9, 9, 11, 0.45);
119
+ --color-rail-bg: #ffffff;
120
+ --color-hover: rgba(24, 24, 27, 0.05);
121
+ --color-active: #f1f1f4; /* selected-row / active-nav neutral fill */
122
+ --color-primary-on-fill: #4f46e5; /* primary-tinted text/icon on an active/raised fill, ≥4.5:1 */
123
+
124
+ --shadow-sm: 0 1px 1px rgba(16, 24, 40, 0.04);
125
+ --shadow-md: 0 2px 6px -2px rgba(16, 24, 40, 0.08), 0 1px 2px rgba(16, 24, 40, 0.04);
126
+ --shadow-lg: 0 10px 28px -10px rgba(16, 24, 40, 0.16);
127
+ }
128
+
129
+ /* ===================================================================== *
130
+ * Admin theme — Dark (zinc-950 canvas, brighter indigo, border-led depth)
131
+ * ===================================================================== */
132
+ :root[data-theme="dark"] {
133
+ color-scheme: dark;
134
+
135
+ --color-bg: #09090b; /* zinc-950 */
136
+ --color-surface: #18181b; /* zinc-900 */
137
+ --color-surface-2: #27272a; /* zinc-800 */
138
+ --color-text: #fafafa;
139
+ --color-text-muted: #a1a1aa; /* zinc-400 */
140
+ --color-text-subtle: #71717a; /* zinc-500 */
141
+ --color-border: #27272a; /* zinc-800 — hairline (decorative) */
142
+ --color-border-strong: #3f3f46; /* zinc-700 */
143
+ --color-control-border: #71717a; /* zinc-500 — ~3.6:1 on the dark surface (WCAG 1.4.11) */
144
+ --color-primary: #6366f1; /* indigo-500 — lifts on dark */
145
+ --color-primary-hover: #818cf8; /* indigo-400 */
146
+ --color-on-primary: #ffffff;
147
+ --color-primary-solid: #4f46e5; /* stays indigo-600 (not primary-500) so white text keeps AA (6.29:1) */
148
+ --color-primary-soft: rgba(99, 102, 241, 0.16);
149
+ --color-danger: #f87171;
150
+ --color-danger-solid: #dc2626; /* deep enough for white text on the dark canvas */
151
+ --color-on-danger: #ffffff;
152
+ --color-success: #4ade80;
153
+ --color-warning: #fbbf24;
154
+ --color-warning-text: #fbbf24; /* on the dark canvas the bright amber already clears 4.5:1 */
155
+ --color-highlight: #854d0e;
156
+ --color-on-highlight: #fef3c7;
157
+ --color-focus: #818cf8;
158
+ --color-link: #8ab4f8; /* SERP-style link, dark mode — ~8:1 on the dark surface (WCAG AA) */
159
+ --color-scrim: rgba(0, 0, 0, 0.6);
160
+ --color-rail-bg: #111113;
161
+ --color-hover: rgba(255, 255, 255, 0.06);
162
+
163
+ --shadow-sm: 0 1px 1px rgba(0, 0, 0, 0.4);
164
+ --shadow-md: 0 2px 8px -2px rgba(0, 0, 0, 0.5), 0 1px 2px rgba(0, 0, 0, 0.4);
165
+ --shadow-lg: 0 10px 28px -8px rgba(0, 0, 0, 0.6);
166
+
167
+ /* active nav / selected-row fill reads against the darker rail */
168
+ --color-active: #232327;
169
+ --color-primary-on-fill: #818cf8; /* indigo-400 — 5.25:1 on --color-active (--color-primary is only ~3.5:1) */
170
+ }
171
+
172
+ /* ===================================================================== *
173
+ * Admin shell — structural refinements (border-led depth, tighter radii)
174
+ *
175
+ * Scoped to the themed admin shell (any `data-theme`) so the public SSG
176
+ * site keeps the looser `:root` radii. The admin leans on hairline borders
177
+ * rather than soft shadows; the tighter radii read as a tool, not a card UI.
178
+ * ===================================================================== */
179
+ :root[data-theme] {
180
+ --radius-sm: 4px;
181
+ --radius-md: 6px;
182
+ --radius-lg: 8px;
183
+ }
@@ -0,0 +1,4 @@
1
+ @use 'tokens';
2
+ @use 'reset';
3
+ @use 'base';
4
+ @use 'mixins';
@@ -0,0 +1,30 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import UiFieldset from '../ui/Fieldset.vue'
4
+ import UiButtonGroup from '../ui/ButtonGroup.vue'
5
+ import { nextBooleanValue } from '../../utils/boolean-field'
6
+ import type { FieldComponentProps } from '../../utils/field-component'
7
+
8
+ const props = defineProps<FieldComponentProps>()
9
+ const model = defineModel<boolean>()
10
+
11
+ const { t } = useT()
12
+ const boolOptions = computed(() => [
13
+ { label: t('field.boolean.no'), value: 'false' },
14
+ { label: t('field.boolean.yes'), value: 'true' },
15
+ ])
16
+
17
+ const proxy = computed<string | string[] | null>({
18
+ get: () => (model.value === true ? 'true' : model.value === false ? 'false' : null),
19
+ set: (v) => { model.value = nextBooleanValue(model.value, v) },
20
+ })
21
+ const required = computed(() => !!props.field.required)
22
+ </script>
23
+
24
+ <template>
25
+ <UiFieldset :id="id" :label="name" :error="error" :required="required">
26
+ <template #default="f">
27
+ <UiButtonGroup v-model="proxy" :options="boolOptions" :disabled="disabled" v-bind="f" />
28
+ </template>
29
+ </UiFieldset>
30
+ </template>
@@ -0,0 +1,68 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import UiField from '../ui/Field.vue'
4
+ import UiFieldset from '../ui/Fieldset.vue'
5
+ import UiSelect from '../ui/Select.vue'
6
+ import UiButtonGroup from '../ui/ButtonGroup.vue'
7
+ import UiCheckboxGroup from '../ui/CheckboxGroup.vue'
8
+ import type { FieldComponentProps } from '../../utils/field-component'
9
+ import type { FieldOf } from '../../../../core/server/utils/defineCollection'
10
+
11
+ const props = defineProps<FieldComponentProps>()
12
+ const model = defineModel<string | string[] | null>()
13
+
14
+ const cfg = computed(() => {
15
+ if (props.field.type !== 'choice') return { choices: [] as { label: string; value: string }[], multiple: false, buttons: false }
16
+ const o = (props.field as FieldOf<'choice'>).options
17
+ return {
18
+ choices: o.choices,
19
+ multiple: !!o.multiple,
20
+ buttons: o.display === 'buttons',
21
+ }
22
+ })
23
+ const required = computed(() => !!props.field.required)
24
+
25
+ // Narrowing proxies so each control gets its precise model type. For the single <select>, map null↔''
26
+ // (the empty option's value) so an optional choice can REPRESENT "no value" (null shows the empty option
27
+ // instead of defaulting to the first choice) and be CLEARED back to null by re-selecting it.
28
+ const single = computed<string | null>({
29
+ get: () => (typeof model.value === 'string' ? model.value : ''),
30
+ set: (v) => { model.value = v ? v : null },
31
+ })
32
+ // An optional single choice gets a leading empty option so null is selectable/clearable; a required one
33
+ // has none (validation enforces a pick), keeping its existing behaviour.
34
+ const singleOptions = computed(() =>
35
+ required.value ? cfg.value.choices : [{ label: '—', value: '' }, ...cfg.value.choices],
36
+ )
37
+ const multi = computed<string[]>({
38
+ get: () => (Array.isArray(model.value) ? model.value : []),
39
+ set: (v) => { model.value = v },
40
+ })
41
+ </script>
42
+
43
+ <template>
44
+ <UiField v-if="!cfg.multiple && !cfg.buttons" :id="id" :label="name" :error="error" :required="required">
45
+ <template #default="f">
46
+ <UiSelect v-model="single" :options="singleOptions" :disabled="disabled" v-bind="f" />
47
+ </template>
48
+ </UiField>
49
+ <UiFieldset v-else :id="id" :label="name" :error="error" :required="required">
50
+ <template #default="f">
51
+ <UiButtonGroup
52
+ v-if="cfg.buttons"
53
+ v-model="model"
54
+ :options="cfg.choices"
55
+ :multiple="cfg.multiple"
56
+ :disabled="disabled"
57
+ v-bind="f"
58
+ />
59
+ <UiCheckboxGroup
60
+ v-else
61
+ v-model="multi"
62
+ :options="cfg.choices"
63
+ :disabled="disabled"
64
+ v-bind="f"
65
+ />
66
+ </template>
67
+ </UiFieldset>
68
+ </template>
@@ -0,0 +1,96 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import UiField from '../ui/Field.vue'
4
+ import UiFieldset from '../ui/Fieldset.vue'
5
+ import UiTimeInput from '../ui/TimeInput.vue'
6
+ import UiDatePicker from '../ui/DatePicker.vue'
7
+ import UiDateRangePicker from '../ui/DateRangePicker.vue'
8
+ import type { FieldComponentProps } from '../../utils/field-component'
9
+ import type { FieldOf } from '../../../../core/server/utils/defineCollection'
10
+
11
+ const { t } = useT()
12
+ const props = defineProps<FieldComponentProps>()
13
+ const model = defineModel<string | { start: string; end: string } | null>()
14
+
15
+ const cfg = computed(() => {
16
+ if (props.field.type !== 'datetime') return { precision: 'datetime' as const, range: false }
17
+ const o = (props.field as FieldOf<'datetime'>).options
18
+ return { precision: o?.precision ?? 'datetime', range: !!o?.range }
19
+ })
20
+ const required = computed(() => !!props.field.required)
21
+ const isTime = computed(() => cfg.value.precision === 'time')
22
+ const pickerPrecision = computed<'date' | 'datetime'>(() => (cfg.value.precision === 'date' ? 'date' : 'datetime'))
23
+
24
+ const single = computed<string | null>({
25
+ get: () => (typeof model.value === 'string' ? model.value : null),
26
+ set: (v) => { model.value = v },
27
+ })
28
+ const range = computed<{ start: string; end: string } | null>({
29
+ get: () => (model.value && typeof model.value === 'object' ? model.value : null),
30
+ set: (v) => { model.value = v },
31
+ })
32
+ const rangeStart = computed<string | null>({
33
+ get: () => range.value?.start ?? null,
34
+ set: (v) => { model.value = { start: v ?? '', end: range.value?.end ?? '' } },
35
+ })
36
+ const rangeEnd = computed<string | null>({
37
+ get: () => range.value?.end ?? null,
38
+ set: (v) => { model.value = { start: range.value?.start ?? '', end: v ?? '' } },
39
+ })
40
+ </script>
41
+
42
+ <template>
43
+ <UiField v-if="!cfg.range" :id="id" :label="name" :error="error" :required="required">
44
+ <template #default="f">
45
+ <UiTimeInput v-if="isTime" v-model="single" :disabled="disabled" v-bind="f" />
46
+ <UiDatePicker
47
+ v-else
48
+ v-model="single"
49
+ :precision="pickerPrecision"
50
+ :disabled="disabled"
51
+ :aria-label="name"
52
+ :describedby="f['aria-describedby']"
53
+ :invalid="f['aria-invalid'] === 'true'"
54
+ :required="!!f.required"
55
+ />
56
+ </template>
57
+ </UiField>
58
+
59
+ <UiFieldset v-else :id="id" :label="name" :error="error" :required="required">
60
+ <template #default="f">
61
+ <template v-if="isTime">
62
+ <label class="field-datetime__sub">
63
+ <span class="field-datetime__sublabel">{{ t('field.datetime.range_start') }}</span>
64
+ <UiTimeInput v-model="rangeStart" :disabled="disabled" v-bind="f" />
65
+ </label>
66
+ <label class="field-datetime__sub">
67
+ <span class="field-datetime__sublabel">{{ t('field.datetime.range_end') }}</span>
68
+ <UiTimeInput v-model="rangeEnd" :disabled="disabled" v-bind="f" />
69
+ </label>
70
+ </template>
71
+ <UiDateRangePicker
72
+ v-else
73
+ v-model="range"
74
+ :precision="pickerPrecision"
75
+ :disabled="disabled"
76
+ :aria-label="name"
77
+ :describedby="f['aria-describedby']"
78
+ :invalid="f['aria-invalid'] === 'true'"
79
+ :required="!!f.required"
80
+ />
81
+ </template>
82
+ </UiFieldset>
83
+ </template>
84
+
85
+ <style lang="scss">
86
+ .field-datetime__sub {
87
+ display: flex;
88
+ align-items: center;
89
+ gap: var(--space-2);
90
+ }
91
+ .field-datetime__sublabel {
92
+ font-size: var(--text-sm);
93
+ color: var(--color-text-muted);
94
+ min-width: 3rem;
95
+ }
96
+ </style>
@@ -0,0 +1,28 @@
1
+ <script setup lang="ts">
2
+ // The per-field stale-reference warning, rendered by the shared FieldLayout. Non-blocking note,
3
+ // distinct from the field's red validation error. Lives in the ui layer so the shared renderer
4
+ // references it without a ui→admin dependency; its path-derived auto-import name stays
5
+ // `FieldDeadRefNote`, so callers are unchanged.
6
+ defineProps<{ show: boolean }>()
7
+ const { t } = useT()
8
+ </script>
9
+
10
+ <template>
11
+ <p v-if="show" class="field-dead-ref" role="note">
12
+ <UiIcon name="triangle-alert" :size="13" class="field-dead-ref__icon" />{{ t('deadRefs.fieldNote') }}
13
+ </p>
14
+ </template>
15
+
16
+ <style lang="scss">
17
+ .field-dead-ref {
18
+ display: flex;
19
+ align-items: center;
20
+ gap: var(--space-1);
21
+ margin: 0;
22
+ font-size: var(--text-xs);
23
+ color: var(--color-warning-text); /* warning conveyed as TEXT → needs 4.5:1, not the 3:1 icon accent */
24
+ }
25
+ .field-dead-ref__icon {
26
+ flex-shrink: 0;
27
+ }
28
+ </style>
@@ -0,0 +1,63 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, watch } from 'vue'
3
+ import UiField from '../ui/Field.vue'
4
+ import UiTextarea from '../ui/Textarea.vue'
5
+ import { fieldConstraints } from '../../../../fields/app/utils/field-meta'
6
+ import { tryParseJson } from '../../../../fields/app/utils/field-value'
7
+ import type { FieldComponentProps } from '../../utils/field-component'
8
+
9
+ const props = defineProps<FieldComponentProps>()
10
+ const model = defineModel<unknown>()
11
+ const c = computed(() => fieldConstraints(props.field))
12
+
13
+ const serialize = (v: unknown) => (v == null ? '' : JSON.stringify(v, null, 2))
14
+ const raw = ref(serialize(model.value))
15
+ const localError = ref<string | null>(null)
16
+
17
+ // Reseed only when the model genuinely differs from what the textarea holds, so our own valid edits
18
+ // never clobber the user's in-progress text. The watch only fires on a model CHANGE, and onInput never
19
+ // writes `model` while the text is invalid — so a fire while the textarea holds invalid JSON is always
20
+ // an EXTERNAL change (applyFrom / reset). That invalid text is not an edit worth protecting: take the
21
+ // external value and clear the stale error, rather than silently dropping the update.
22
+ watch(model, (v) => {
23
+ const current = tryParseJson(raw.value)
24
+ if (!current.ok) {
25
+ raw.value = serialize(v)
26
+ localError.value = null
27
+ return
28
+ }
29
+ if (JSON.stringify(v) !== JSON.stringify(current.value)) raw.value = serialize(v)
30
+ })
31
+
32
+ function onInput(value: string | null | undefined) {
33
+ raw.value = value ?? ''
34
+ if (raw.value.trim() === '') {
35
+ localError.value = null
36
+ model.value = null
37
+ return
38
+ }
39
+ const parsed = tryParseJson(raw.value)
40
+ if (parsed.ok) {
41
+ localError.value = null
42
+ model.value = parsed.value
43
+ } else {
44
+ localError.value = 'Invalid JSON.'
45
+ }
46
+ }
47
+
48
+ const shownError = computed(() => localError.value ?? props.error ?? null)
49
+ </script>
50
+
51
+ <template>
52
+ <UiField :id="id" :label="name" :error="shownError" :required="c.required">
53
+ <template #default="f">
54
+ <UiTextarea
55
+ :model-value="raw"
56
+ :rows="6"
57
+ :disabled="disabled"
58
+ v-bind="f"
59
+ @update:model-value="onInput"
60
+ />
61
+ </template>
62
+ </UiField>
63
+ </template>