@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,199 @@
1
+ <script setup lang="ts">
2
+ // The editor "Ampel": TWO lamps side by side in the action bar, each a dot + ONE word + a details tooltip.
3
+ // LEFT — the SAVE / draft lifecycle of THIS record (its local vs. stored state):
4
+ // amber — work in flight: saving, or unsaved local changes not yet in the DB
5
+ // blue — saved but a Draft, so it is intentionally NOT on the live/static site
6
+ // green — saved and (for status collections) published, or just saved (no status field)
7
+ // RIGHT — the GENERATED state of this record's static page (pageLike collections only), read from
8
+ // `publish_status` via `/api/publish-status`:
9
+ // green — the route's last publish succeeded (the file is live; the tooltip names local vs S3)
10
+ // amber — published but no success row yet — a republish is in flight ("Generating…")
11
+ // neutral — this environment never produces the file (dev / static output off) → "Not built"
12
+ // red — the last publish for the route errored (render / write / S3) — details in the tooltip
13
+ // blue — a Draft: intentionally not generated, so there is no live file
14
+ import type { PublishStatusData } from '../composables/usePublishStatus'
15
+
16
+ const props = defineProps<{
17
+ dirty: boolean
18
+ saving?: boolean
19
+ hasStatus?: boolean
20
+ /** The LIVE (possibly unsaved) form status — drives the left lamp's word. */
21
+ status?: string
22
+ /** The last SAVED publish status — drives the RIGHT lamp's draft/published decision, so an unsaved
23
+ * dropdown switch never flips the generated-state indicator. Falls back to `status` when absent. */
24
+ savedStatus?: string
25
+ /** Only pageLike collections produce a static page → only they get the right (live) lamp. */
26
+ pageLike?: boolean
27
+ /** The record's last publish outcome (from `/api/publish-status`); null/absent while unknown. */
28
+ live?: PublishStatusData | null
29
+ }>()
30
+ const { t, lang } = useT()
31
+
32
+ // LEFT lamp — save + publish state. `word` is the single label shown; `detail` is the tooltip sentence.
33
+ const save = computed<{ tone: 'amber' | 'green' | 'blue'; word: string; detail: string }>(() => {
34
+ if (props.saving) return { tone: 'amber', word: t('editorStatus.word.saving'), detail: t('editorStatus.detail.saving') }
35
+ if (props.dirty) return { tone: 'amber', word: t('editorStatus.word.unsaved'), detail: t('editorStatus.detail.unsaved') }
36
+ if (props.hasStatus && props.status === 'draft') return { tone: 'blue', word: t('editorStatus.word.draft'), detail: t('editorStatus.detail.draft') }
37
+ if (props.hasStatus) return { tone: 'green', word: t('editorStatus.word.published'), detail: t('editorStatus.detail.published') }
38
+ return { tone: 'green', word: t('editorStatus.word.saved'), detail: t('editorStatus.detail.saved') }
39
+ })
40
+
41
+ const updatedAtLabel = computed(() => {
42
+ const at = props.live?.updatedAt
43
+ if (!at) return ''
44
+ const d = new Date(at)
45
+ if (Number.isNaN(d.getTime())) return ''
46
+ return t('editorStatus.live.updatedAt', { time: d.toLocaleString(lang.value) })
47
+ })
48
+
49
+ // RIGHT lamp — the live / generated state of this record's static page. Only a SAVED pageLike record with a
50
+ // resolved route has one (a non-pageLike collection produces no static page; an unsaved `new` record leaves
51
+ // `live.route` null → no right lamp until the record actually has a page).
52
+ const live = computed<{ tone: 'green' | 'red' | 'amber' | 'blue' | 'neutral'; word: string; detail: string; when?: string; error?: string } | null>(() => {
53
+ if (props.pageLike !== true || !props.live?.route) return null
54
+ // Key the draft/published decision off the SAVED status, not the live form value: an unsaved dropdown
55
+ // switch must not move the generated-state indicator. Switching a live page → Draft keeps it "Live" (the
56
+ // file stays on the server until the save prunes it); switching a saved Draft → Published keeps it "Not
57
+ // live" (nothing is generated until the publish is actually saved). A DISTINCT word from the left lamp's
58
+ // "Draft" so the two lamps read as two signals (save-state vs generated-state), not a repeat.
59
+ const saved = props.savedStatus ?? props.status
60
+ if (props.hasStatus && saved === 'draft')
61
+ return { tone: 'blue', word: t('editorStatus.word.notLive'), detail: t('editorStatus.live.draft') }
62
+ const s = props.live?.status ?? null
63
+ if (s === 'error')
64
+ return { tone: 'red', word: t('editorStatus.word.error'), detail: t('editorStatus.live.error'), when: updatedAtLabel.value, error: props.live?.error ?? '' }
65
+ if (s === 'success') {
66
+ // Where the file actually landed — surface local vs S3 in the tooltip ("stored on S3").
67
+ const onS3 = (props.live?.target ?? props.live?.driver) === 's3'
68
+ return { tone: 'green', word: t('editorStatus.word.live'), detail: t(onS3 ? 'editorStatus.live.liveS3' : 'editorStatus.live.liveLocal'), when: updatedAtLabel.value }
69
+ }
70
+ // No success row yet. Only an explicit generates:false means "this environment produces nothing" (dev /
71
+ // static output off) → a calm neutral "Not built". Unknown (undefined) → assume a publish is in flight.
72
+ if (props.live?.generates === false)
73
+ return { tone: 'neutral', word: t('editorStatus.word.notBuilt'), detail: t('editorStatus.live.notBuilt') }
74
+ return { tone: 'amber', word: t('editorStatus.word.generating'), detail: t('editorStatus.live.pending') }
75
+ })
76
+ </script>
77
+
78
+ <template>
79
+ <span class="editor-status-group">
80
+ <!-- Polite live region: announces save-state transitions (Saved → Unsaved → Saving…) to screen readers.
81
+ Visually hidden; not a tooltip. -->
82
+ <span class="editor-status-sr" role="status" aria-live="polite">{{ save.detail }}</span>
83
+
84
+ <UiTooltip side="bottom">
85
+ <!-- A focusable status indicator, NOT a <button>: it has no action, and a button whose accessible
86
+ name contains "Saved"/"Unsaved" would collide with `getByRole('button', {name:'Save'})`. aria-label
87
+ leads with the visible word so it stays part of the accessible name, then adds the detail for AT. -->
88
+ <span class="editor-status" tabindex="0" :data-tone="save.tone" :aria-label="`${save.word} — ${save.detail}`">
89
+ <span class="editor-status__dot" aria-hidden="true" />
90
+ <span class="editor-status__word">{{ save.word }}</span>
91
+ </span>
92
+ <template #content>
93
+ <span class="editor-status-tip">
94
+ <strong class="editor-status-tip__detail">{{ save.detail }}</strong>
95
+ </span>
96
+ </template>
97
+ </UiTooltip>
98
+
99
+ <UiTooltip v-if="live" side="bottom">
100
+ <span class="editor-status-live" tabindex="0" :data-tone="live.tone" :aria-label="`${live.word} — ${live.detail}`">
101
+ <span class="editor-status-live__dot" aria-hidden="true" />
102
+ <span class="editor-status-live__word">{{ live.word }}</span>
103
+ </span>
104
+ <template #content>
105
+ <span class="editor-status-tip">
106
+ <strong class="editor-status-tip__detail">{{ live.detail }}</strong>
107
+ <span v-if="live.when" class="editor-status-tip__when">{{ live.when }}</span>
108
+ <span v-if="live.tone === 'red' && live.error" class="editor-status-tip__err">{{ live.error }}</span>
109
+ </span>
110
+ </template>
111
+ </UiTooltip>
112
+ </span>
113
+ </template>
114
+
115
+ <style scoped lang="scss">
116
+ .editor-status-group {
117
+ display: inline-flex;
118
+ align-items: center;
119
+ gap: var(--space-3);
120
+ }
121
+
122
+ // Visually-hidden polite status region (mirrors the ui sr-only mixin; admin can't @use a ui-layer scss).
123
+ .editor-status-sr {
124
+ position: absolute;
125
+ width: 1px;
126
+ height: 1px;
127
+ padding: 0;
128
+ margin: -1px;
129
+ overflow: hidden;
130
+ clip: rect(0, 0, 0, 0);
131
+ white-space: nowrap;
132
+ border: 0;
133
+ }
134
+
135
+ // Both lamps are focusable status indicators (tabindex spans, not buttons) so the tooltip opens on hover
136
+ // AND keyboard focus, without adding a "Save"-named button that would clash with Save-button selectors.
137
+ .editor-status,
138
+ .editor-status-live {
139
+ display: inline-flex;
140
+ align-items: center;
141
+ gap: var(--space-2);
142
+ cursor: help;
143
+
144
+ &__dot {
145
+ width: 0.6rem;
146
+ height: 0.6rem;
147
+ border-radius: var(--radius-full);
148
+ background: var(--_tone, var(--color-text-subtle));
149
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--_tone, transparent) 22%, transparent);
150
+ flex: none;
151
+ }
152
+ &__word {
153
+ font-size: var(--text-sm);
154
+ color: var(--color-text-muted);
155
+ white-space: nowrap;
156
+ }
157
+
158
+ &[data-tone='amber'] {
159
+ --_tone: var(--color-warning);
160
+ }
161
+ &[data-tone='green'] {
162
+ --_tone: var(--color-success);
163
+ }
164
+ &[data-tone='blue'] {
165
+ --_tone: var(--color-primary);
166
+ }
167
+ &[data-tone='red'] {
168
+ --_tone: var(--color-danger);
169
+ }
170
+ &[data-tone='neutral'] {
171
+ --_tone: var(--color-text-subtle);
172
+ }
173
+ &:focus-visible {
174
+ outline: 2px solid var(--color-primary);
175
+ outline-offset: 2px;
176
+ border-radius: var(--radius-sm);
177
+ }
178
+ }
179
+ </style>
180
+
181
+ <!-- Not scoped: the tooltip content is teleported to <body> by Reka, where a scoped data-v selector no
182
+ longer matches (same reason UiTooltip itself keeps global styles). -->
183
+ <style lang="scss">
184
+ .editor-status-tip {
185
+ display: flex;
186
+ flex-direction: column;
187
+ gap: var(--space-1);
188
+
189
+ &__when {
190
+ color: var(--color-text-muted);
191
+ font-size: var(--text-xs);
192
+ }
193
+ &__err {
194
+ color: var(--color-danger);
195
+ font-size: var(--text-xs);
196
+ word-break: break-word;
197
+ }
198
+ }
199
+ </style>
@@ -0,0 +1,28 @@
1
+ <script setup lang="ts">
2
+ // Fallback body when a collection's `editor` type has no registered body (an extension misconfig or a
3
+ // missing plugin). Fails visibly instead of rendering blank.
4
+ defineProps<{ editor: string }>()
5
+ const { t } = useT()
6
+ </script>
7
+
8
+ <template>
9
+ <div class="editor-unsupported">
10
+ <p class="editor-unsupported__note">{{ t('editor.unsupported', { type: editor }) }}</p>
11
+ </div>
12
+ </template>
13
+
14
+ <style lang="scss">
15
+ .editor-unsupported {
16
+ flex: 1 1 auto;
17
+ min-height: 0;
18
+
19
+ &__note {
20
+ font-size: var(--text-sm);
21
+ color: var(--color-text-muted);
22
+ padding: var(--space-3) var(--space-4);
23
+ border: 1px solid var(--color-border);
24
+ border-radius: var(--radius-md);
25
+ background: var(--color-surface-2);
26
+ }
27
+ }
28
+ </style>
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ // The schema-driven default editor body: a plain field list. All state comes from the editor form
3
+ // context provided by CollectionEditor; this body only lays it out.
4
+ const ctx = useEditorFormContext()
5
+ </script>
6
+
7
+ <template>
8
+ <div class="editor__flat">
9
+ <PageFieldsPane v-bind="ctx.pageFieldsBindings.value" v-on="ctx.pageFieldsHandlers" />
10
+ </div>
11
+ </template>
12
+
13
+ <style lang="scss">
14
+ // Flat-collection field list: its own scroll region, so the shell's actions row stays pinned below it.
15
+ .editor__flat {
16
+ flex: 1 1 auto;
17
+ min-height: 0;
18
+ overflow-y: auto;
19
+ display: flex;
20
+ flex-direction: column;
21
+ gap: var(--space-4);
22
+ }
23
+ </style>
@@ -0,0 +1,105 @@
1
+ <script setup lang="ts">
2
+ // Pruvious-style content-locale switcher for the record editor. Shows every configured website locale:
3
+ // the active one as a label, an existing sibling as a pen (edit) + copy control, a missing one as a
4
+ // "+" (create) link. Content locales only — not the admin-UI language.
5
+ const props = defineProps<{
6
+ collection: string
7
+ id: string
8
+ mode: 'single' | 'multi'
9
+ current: string
10
+ translations: Record<string, number | null>
11
+ group?: string
12
+ }>()
13
+ const emit = defineEmits<{ copyFrom: [locale: string] }>()
14
+
15
+ const { locales } = useContentLocales()
16
+
17
+ /** Existing sibling row id for a locale (multi only), or null when there is no translation yet. */
18
+ function existingId(loc: string): number | null {
19
+ const v = props.translations[loc]
20
+ return typeof v === 'number' ? v : null
21
+ }
22
+ function createLink(loc: string): string {
23
+ const q = new URLSearchParams({ locale: loc })
24
+ if (props.group) q.set('group', props.group)
25
+ return `/admin/${props.collection}/new?${q.toString()}`
26
+ }
27
+ const up = (s: string) => s.toUpperCase()
28
+ const { t } = useT()
29
+ </script>
30
+
31
+ <template>
32
+ <div class="locale-bar" role="group" :aria-label="t('localeBar.groupLabel')">
33
+ <template v-for="loc in locales" :key="loc">
34
+ <span v-if="loc === current" class="locale-bar__item locale-bar__item--active" aria-current="true">{{ up(loc) }}</span>
35
+
36
+ <NuxtLink
37
+ v-else-if="mode === 'single'"
38
+ class="locale-bar__item locale-bar__btn"
39
+ :to="`/admin/${collection}?locale=${loc}`"
40
+ :aria-label="t('localeBar.editLocale', { loc: up(loc) })"
41
+ >{{ up(loc) }}<UiIcon name="pencil" :size="14" /></NuxtLink>
42
+
43
+ <span v-else-if="existingId(loc) !== null" class="locale-bar__item">
44
+ <NuxtLink class="locale-bar__btn" :to="`/admin/${collection}/${existingId(loc)}?locale=${loc}`" :aria-label="t('localeBar.editLocale', { loc: up(loc) })">{{ up(loc) }}<UiIcon name="pencil" :size="14" /></NuxtLink>
45
+ <button type="button" class="locale-bar__btn locale-bar__btn--copy" :aria-label="t('localeBar.copyInto', { loc: up(loc), current: up(current) })" @click="emit('copyFrom', loc)"><UiIcon name="copy" :size="14" /></button>
46
+ </span>
47
+
48
+ <NuxtLink
49
+ v-else
50
+ class="locale-bar__item locale-bar__btn locale-bar__btn--add"
51
+ :to="createLink(loc)"
52
+ :aria-label="t('localeBar.createTranslation', { loc: up(loc) })"
53
+ >{{ up(loc) }}<UiIcon name="plus" :size="14" /></NuxtLink>
54
+ </template>
55
+ </div>
56
+ </template>
57
+
58
+ <style lang="scss">
59
+ .locale-bar {
60
+ display: flex;
61
+ align-items: center;
62
+ gap: var(--space-2);
63
+
64
+ &__item {
65
+ display: inline-flex;
66
+ align-items: center;
67
+ gap: var(--space-1);
68
+ font-size: var(--text-sm);
69
+ font-weight: var(--weight-medium);
70
+
71
+ &--active {
72
+ padding: var(--space-1) var(--space-2);
73
+ border-radius: var(--radius-sm);
74
+ background: var(--color-surface);
75
+ border: 1px solid var(--color-border);
76
+ color: var(--color-text);
77
+ }
78
+ }
79
+ &__btn {
80
+ display: inline-flex;
81
+ align-items: center;
82
+ gap: var(--space-1);
83
+ padding: var(--space-1) var(--space-2);
84
+ border: 1px solid var(--color-border);
85
+ border-radius: var(--radius-sm);
86
+ background: transparent;
87
+ color: var(--color-text-muted);
88
+ text-decoration: none;
89
+ cursor: pointer;
90
+
91
+ &:hover {
92
+ border-color: var(--color-text-muted);
93
+ color: var(--color-text);
94
+ }
95
+ &--add {
96
+ border-style: solid;
97
+ border-color: var(--color-border);
98
+ color: var(--color-text-muted);
99
+ }
100
+ &--copy {
101
+ padding: var(--space-1);
102
+ }
103
+ }
104
+ }
105
+ </style>
@@ -0,0 +1,101 @@
1
+ <script setup lang="ts">
2
+ import type { FieldDef } from '../../../core/server/utils/defineCollection'
3
+ import type { LayoutNode } from '../../../core/server/utils/field-layout'
4
+ import type { SeoMeta } from '../../../core/server/utils/seo'
5
+
6
+ // The collection (page) field list — delegates to the shared <FieldLayout> renderer (rows / groups /
7
+ // widths, or one field per row when the collection declares no layout), shared by the flat-collection
8
+ // form and the 3-pane editor's root-selected pane so the wiring lives in exactly one place. pageLike
9
+ // collections also get their routable `path` (slug); seo-enabled ones get the SEO section — both as
10
+ // page-level system fields bracketing the collection fields.
11
+ const props = defineProps<{
12
+ fields: Record<string, FieldDef>
13
+ /** Normalized admin editor layout (from the serialized collection). Absent → one field per row. */
14
+ fieldLayout?: LayoutNode[]
15
+ values: Record<string, unknown>
16
+ errors: Record<string, string>
17
+ /** Root field keys holding a stale reference — shown as a non-blocking warning note. */
18
+ deadFields?: Set<string>
19
+ locale: string
20
+ pageLike?: boolean
21
+ seo?: boolean
22
+ status?: boolean
23
+ disabled?: boolean
24
+ }>()
25
+ const emit = defineEmits<{ update: [name: string, value: unknown] }>()
26
+ const { t } = useT()
27
+
28
+ // Live preview of the slug the server will auto-generate from the title while the field is left blank
29
+ // (the server slugifies the title on save). Falls back to '/' when there is no title yet.
30
+ const slugPlaceholder = computed(() => {
31
+ const s = slugify(recordTitle(props.fields, props.values))
32
+ return s ? `/${s}` : '/'
33
+ })
34
+ </script>
35
+
36
+ <template>
37
+ <!-- Publish gate (the `status` system column). The primary page-level control, so it leads the pane:
38
+ Draft keeps the page out of the generated site; Published renders + auto-publishes it. -->
39
+ <UiField
40
+ v-if="status"
41
+ class="page-settings__status"
42
+ :label="t('pageSettings.statusLabel')"
43
+ :hint="t('pageSettings.statusHint')"
44
+ >
45
+ <template #default="f">
46
+ <UiSelect
47
+ :model-value="(values.status as string) ?? 'draft'"
48
+ :options="[
49
+ { label: t('pageSettings.statusDraft'), value: 'draft' },
50
+ { label: t('pageSettings.statusPublished'), value: 'published' },
51
+ ]"
52
+ :disabled="disabled"
53
+ v-bind="f"
54
+ @update:model-value="(v) => emit('update', 'status', v)"
55
+ />
56
+ </template>
57
+ </UiField>
58
+
59
+ <!-- The collection (page) fields, laid out by the shared renderer (rows / groups / widths, or one field
60
+ per row when the collection declares no `fieldLayout`). The condition gate + dead-ref note live inside. -->
61
+ <FieldLayout
62
+ :layout="fieldLayout"
63
+ :fields="fields"
64
+ :values="values"
65
+ :errors="errors"
66
+ :dead-fields="deadFields"
67
+ :locale="locale"
68
+ :disabled="disabled"
69
+ @update="(name, value) => emit('update', name, value)"
70
+ />
71
+
72
+ <!-- Page slug (the routable path). A system column on pageLike collections, edited like a field. -->
73
+ <UiField
74
+ v-if="pageLike"
75
+ class="page-settings__slug"
76
+ :label="t('pageSettings.slugLabel')"
77
+ :hint="t('pageSettings.slugHint')"
78
+ :error="errors.path || null"
79
+ >
80
+ <template #default="f">
81
+ <UiTextInput
82
+ :model-value="(values.path as string) ?? ''"
83
+ :disabled="disabled"
84
+ :placeholder="slugPlaceholder"
85
+ v-bind="f"
86
+ @update:model-value="(v) => emit('update', 'path', v)"
87
+ />
88
+ </template>
89
+ </UiField>
90
+
91
+ <!-- Page SEO (meta title/description/noindex + Google preview). The `seo` JSON system column. -->
92
+ <SeoFields
93
+ v-if="seo"
94
+ :value="(values.seo as SeoMeta) ?? {}"
95
+ :page-title="(values.title as string) ?? ''"
96
+ :path="(values.path as string) ?? ''"
97
+ :locale="locale"
98
+ :disabled="disabled"
99
+ @update="(v) => emit('update', 'seo', v)"
100
+ />
101
+ </template>
@@ -0,0 +1,39 @@
1
+ <script setup lang="ts">
2
+ import type { PageFieldsBindings } from '../utils/editor-form-context'
3
+
4
+ // The page-fields fragment shared by both editor bodies: an optional content-locale switcher
5
+ // (labelled like a page field) sitting above the collection (page) field list. Centralises the
6
+ // LocaleBar + PageFields wiring so the flat form and the 3-pane editor's root pane stay in sync.
7
+ // Props are the shared `PageFieldsBindings` type — the same shape the shell pre-builds and provides.
8
+ defineProps<PageFieldsBindings>()
9
+ const emit = defineEmits<{ copyFrom: [locale: string]; update: [name: string, value: unknown] }>()
10
+ const { t } = useT()
11
+ </script>
12
+
13
+ <template>
14
+ <div v-if="translatable" class="ui-field">
15
+ <span class="ui-field__label">{{ t('localeBar.fieldLabel') }}</span>
16
+ <LocaleBar
17
+ :collection="collection"
18
+ :id="id"
19
+ :mode="mode"
20
+ :current="locale"
21
+ :translations="translations"
22
+ :group="group"
23
+ @copy-from="(loc) => emit('copyFrom', loc)"
24
+ />
25
+ </div>
26
+ <PageFields
27
+ :fields="fields"
28
+ :field-layout="fieldLayout"
29
+ :values="values"
30
+ :errors="errors"
31
+ :dead-fields="deadFields"
32
+ :locale="locale"
33
+ :page-like="pageLike"
34
+ :seo="seo"
35
+ :status="status"
36
+ :disabled="disabled"
37
+ @update="(name, value) => emit('update', name, value)"
38
+ />
39
+ </template>
@@ -0,0 +1,169 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import type { SeoMeta } from '../../../core/server/utils/seo'
4
+ import type { FieldDef } from '../../../core/server/utils/defineCollection'
5
+ import { localePath } from '../../../core/app/utils/locale-path'
6
+
7
+ // The page-level SEO section: edit the meta title/description/noindex (the `seo` system column) with a
8
+ // live Google-result preview. Emits the whole merged SeoMeta so the parent routes it through setField.
9
+ const props = defineProps<{
10
+ value: SeoMeta
11
+ pageTitle?: string
12
+ path?: string
13
+ locale: string
14
+ disabled?: boolean
15
+ }>()
16
+ const emit = defineEmits<{ update: [seo: SeoMeta] }>()
17
+ const { t } = useT()
18
+ const { primary, prefixPrimary } = useContentLocales()
19
+
20
+ // Soft guidance limits (Google truncates ~ these lengths).
21
+ const TITLE_MAX = 60
22
+ const DESC_MAX = 160
23
+
24
+ function patch(next: Partial<SeoMeta>) {
25
+ emit('update', { ...props.value, ...next })
26
+ }
27
+
28
+ const previewTitle = computed(() => props.value.title?.trim() || props.pageTitle?.trim() || t('seo.untitled'))
29
+ const previewDesc = computed(() => props.value.description?.trim() || t('seo.noDescription'))
30
+
31
+ const host = computed(() => {
32
+ try { return useRequestURL().host } catch { return '' }
33
+ })
34
+ const previewUrl = computed(() => `${host.value}${localePath(props.path || '/', props.locale, primary, prefixPrimary)}`)
35
+
36
+ const titleLen = computed(() => (props.value.title ?? '').length)
37
+ const descLen = computed(() => (props.value.description ?? '').length)
38
+
39
+ // The social-share image (og:image) rides the standard media widget over a synthetic single-media field
40
+ // def — same picker/thumb/remove UX as any media field, stored as `seo.image` (a media id).
41
+ const socialImageField = { type: 'media', options: { accept: 'image' } } as FieldDef
42
+ </script>
43
+
44
+ <template>
45
+ <div class="seo-fields">
46
+ <p class="seo-fields__section-label">{{ t('seo.sectionLabel') }}</p>
47
+
48
+ <!-- Google SERP-style preview. Decorative (it mirrors the inputs below) → hidden from SRs. -->
49
+ <div class="seo-preview" aria-hidden="true">
50
+ <div class="seo-preview__url">{{ previewUrl }}</div>
51
+ <div class="seo-preview__title">{{ previewTitle }}</div>
52
+ <div class="seo-preview__desc">{{ previewDesc }}</div>
53
+ </div>
54
+
55
+ <UiField class="seo-fields__title" :label="t('seo.metaTitle')" :hint="`${titleLen}/${TITLE_MAX}`">
56
+ <template #default="f">
57
+ <UiTextInput
58
+ :model-value="value.title ?? ''"
59
+ :placeholder="pageTitle"
60
+ :disabled="disabled"
61
+ v-bind="f"
62
+ @update:model-value="(v) => patch({ title: v ?? '' })"
63
+ />
64
+ </template>
65
+ </UiField>
66
+
67
+ <UiField class="seo-fields__desc" :label="t('seo.metaDescription')" :hint="`${descLen}/${DESC_MAX}`">
68
+ <template #default="f">
69
+ <UiTextarea
70
+ :model-value="value.description ?? ''"
71
+ :rows="3"
72
+ :disabled="disabled"
73
+ v-bind="f"
74
+ @update:model-value="(v) => patch({ description: v ?? '' })"
75
+ />
76
+ </template>
77
+ </UiField>
78
+
79
+ <UiField class="seo-fields__image" :label="t('seo.socialImage')" :hint="t('seo.socialImageHint')">
80
+ <template #default="f">
81
+ <FieldMedia
82
+ :field="socialImageField"
83
+ name="seoImage"
84
+ :locale="locale"
85
+ :disabled="disabled"
86
+ v-bind="f"
87
+ :model-value="value.image ?? null"
88
+ @update:model-value="(v) => patch({ image: typeof v === 'number' ? v : null })"
89
+ />
90
+ </template>
91
+ </UiField>
92
+
93
+ <label class="seo-fields__noindex">
94
+ <UiCheckbox
95
+ :model-value="!!value.noindex"
96
+ :disabled="disabled"
97
+ @update:model-value="(v) => patch({ noindex: v })"
98
+ />
99
+ <span>{{ t('seo.noindexLabel') }}</span>
100
+ </label>
101
+ </div>
102
+ </template>
103
+
104
+ <style lang="scss">
105
+ .seo-fields {
106
+ display: flex;
107
+ flex-direction: column;
108
+ gap: var(--space-4);
109
+
110
+ &__section-label {
111
+ margin: 0;
112
+ font-size: var(--text-sm);
113
+ font-weight: var(--weight-medium);
114
+ color: var(--color-text-muted);
115
+ }
116
+
117
+ &__noindex {
118
+ display: inline-flex;
119
+ align-items: center;
120
+ gap: var(--space-2);
121
+ font-size: var(--text-sm);
122
+ cursor: pointer;
123
+
124
+ input {
125
+ width: 1rem;
126
+ height: 1rem;
127
+ accent-color: var(--color-primary);
128
+ }
129
+ }
130
+ }
131
+
132
+ // A restrained take on a Google result card — enough to gauge truncation, not a pixel-perfect clone.
133
+ .seo-preview {
134
+ padding: var(--space-3);
135
+ border: 1px solid var(--color-border);
136
+ border-radius: var(--radius-md);
137
+ background: var(--color-surface);
138
+
139
+ &__url {
140
+ font-size: var(--text-xs, 0.75rem);
141
+ color: var(--color-text-muted);
142
+ white-space: nowrap;
143
+ overflow: hidden;
144
+ text-overflow: ellipsis;
145
+ }
146
+ &__title {
147
+ margin-top: var(--space-1);
148
+ font-size: var(--text-lg);
149
+ line-height: 1.3;
150
+ color: var(--color-link, #1a0dab);
151
+ overflow: hidden;
152
+ text-overflow: ellipsis;
153
+ display: -webkit-box;
154
+ -webkit-line-clamp: 1;
155
+ line-clamp: 1;
156
+ -webkit-box-orient: vertical;
157
+ }
158
+ &__desc {
159
+ margin-top: var(--space-1);
160
+ font-size: var(--text-sm);
161
+ color: var(--color-text);
162
+ overflow: hidden;
163
+ display: -webkit-box;
164
+ -webkit-line-clamp: 2;
165
+ line-clamp: 2;
166
+ -webkit-box-orient: vertical;
167
+ }
168
+ }
169
+ </style>