@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,370 @@
1
+ import type { SerializedField } from '../../../core/server/utils/serialize-collection'
2
+ import type { LayoutNode } from '../../../core/server/utils/field-layout'
3
+ import type { BlockErrorMap } from '../utils/edit-form'
4
+ import type { DeadRef } from '../utils/dead-refs'
5
+ import { asFieldDef, initialValues, mapServerErrors, parseBlockErrors, reconcileBlockErrors, readFetchError } from '../utils/edit-form'
6
+
7
+ export interface UseEditFormOptions {
8
+ collection: string
9
+ id: string
10
+ /** Requested content locale (from the `?locale` route query). Defaults to the primary locale. */
11
+ locale?: string
12
+ /** translationGroup to link a NEW multi translation to its siblings (from the `?group` query). */
13
+ group?: string
14
+ }
15
+
16
+ export type SubmitResult = { ok: true; record: unknown } | { ok: false }
17
+
18
+ function snapshot(values: Record<string, unknown>): Record<string, unknown> {
19
+ return JSON.parse(JSON.stringify(values))
20
+ }
21
+
22
+ export function useEditForm(opts: UseEditFormOptions) {
23
+ const { collection, id } = opts
24
+ const { t } = useT()
25
+ const { primary } = useContentLocales()
26
+ // The locale the editor *requests* (single GET/PUT + a new multi translation). For an existing multi
27
+ // row the active locale comes from the row itself (set in init); it can't be switched via a query.
28
+ const requestedLocale = opts.locale?.trim() || primary
29
+ const activeLocale = ref(requestedLocale)
30
+ const translations = ref<Record<string, number | null>>({})
31
+ // Stale references this record currently holds (derived on read) — drives the editor's dead-ref warnings.
32
+ const deadRefs = ref<DeadRef[]>([])
33
+ // The translation group the LocaleBar links new siblings to: the requested group for a new
34
+ // translation, or the loaded row's own group for an existing multi record.
35
+ const translationGroup = ref<string | undefined>(opts.group?.trim() || undefined)
36
+
37
+ const fields = ref<Record<string, SerializedField>>({})
38
+ // The collection's normalized admin editor layout (resolved server-side), or undefined → one field per row.
39
+ const fieldLayout = ref<LayoutNode[] | undefined>(undefined)
40
+ const mode = ref<'multi' | 'single'>('multi')
41
+ const translatable = ref(false)
42
+ const blocksEnabled = ref(false)
43
+ const blocksAllowed = ref<string[] | undefined>(undefined)
44
+ // pageLike collections carry a routable `path` (the slug) as a system column, edited in the page editor.
45
+ const pageLike = ref(false)
46
+ // seo-enabled collections carry a `seo` JSON system column ({ title, description, noindex }).
47
+ const hasSeo = ref(false)
48
+ // status-enabled collections carry a `status` system column ('draft' | 'published') — the publish gate.
49
+ const hasStatus = ref(false)
50
+ // Which editor body renders (the `editor` type on the serialized collection). The server resolves it,
51
+ // but fall back defensively (blocks → 'blocks', else 'fields') so an older/partial schema still works.
52
+ const editorType = ref('fields')
53
+
54
+ const values = reactive<Record<string, unknown>>({})
55
+ const errors = reactive<Record<string, string>>({})
56
+ const formError = ref('')
57
+ const blockErrors = ref<BlockErrorMap>({})
58
+ const saving = ref(false)
59
+ const baseline = ref<Record<string, unknown>>({})
60
+ const baseUpdatedAt = ref<number | undefined>(undefined)
61
+
62
+ const dirty = computed(() => !valuesEqual(values, baseline.value))
63
+ // The LAST SAVED publish status (from the baseline snapshot, not the live edited value). The editor's
64
+ // right "live" lamp keys its draft/published decision off this — an UNSAVED dropdown switch must not flip
65
+ // the generated-state indicator (nothing is generated until the change is saved). '' when statusless.
66
+ const savedStatus = computed(() => (hasStatus.value ? ((baseline.value.status as string | undefined) ?? '') : ''))
67
+ // The LAST SAVED page path (baseline, not the live edited slug). The preview iframe + the header's
68
+ // open-in-new-tab must target a URL that actually EXISTS — an unsaved slug edit would 404 until saved;
69
+ // on save the baseline moves and both follow to the new URL. '' when non-pageLike or never saved.
70
+ const savedPath = computed(() => (pageLike.value ? ((baseline.value.path as string | undefined) ?? '') : ''))
71
+
72
+ // Undo/redo: snapshot history of the whole form state. Every edit funnels through setField (block-tree
73
+ // ops route through setField('content', …)), so that's the single capture point. A rapid burst of edits
74
+ // to the same field coalesces into one step; the stacks reset on load/save (no undo across a save).
75
+ const past = ref<Record<string, unknown>[]>([])
76
+ const future = ref<Record<string, unknown>[]>([])
77
+ const canUndo = computed(() => past.value.length > 0)
78
+ const canRedo = computed(() => future.value.length > 0)
79
+ let coalesceKey = ''
80
+ let coalesceAt = 0
81
+ const COALESCE_MS = 600
82
+ const HISTORY_LIMIT = 100
83
+
84
+ function recordHistory(name: string) {
85
+ const now = Date.now()
86
+ const coalesce = name === coalesceKey && now - coalesceAt < COALESCE_MS
87
+ coalesceKey = name
88
+ coalesceAt = now
89
+ if (coalesce) return
90
+ past.value.push(snapshot(values))
91
+ if (past.value.length > HISTORY_LIMIT) past.value.shift()
92
+ future.value = []
93
+ }
94
+
95
+ function restore(snap: Record<string, unknown>) {
96
+ const fresh = snapshot(snap)
97
+ for (const k of Object.keys(fresh)) values[k] = fresh[k]
98
+ for (const k of Object.keys(values)) if (!(k in fresh)) delete values[k]
99
+ for (const k of Object.keys(errors)) delete errors[k]
100
+ blockErrors.value = {}
101
+ formError.value = ''
102
+ coalesceKey = ''
103
+ }
104
+
105
+ function undo() {
106
+ if (!past.value.length) return
107
+ future.value.push(snapshot(values))
108
+ restore(past.value.pop()!)
109
+ }
110
+ function redo() {
111
+ if (!future.value.length) return
112
+ past.value.push(snapshot(values))
113
+ restore(future.value.pop()!)
114
+ }
115
+
116
+ const renderableFields = computed<Record<string, SerializedField>>(() => {
117
+ if (!blocksEnabled.value) return fields.value
118
+ const { content: _content, ...rest } = fields.value
119
+ return rest
120
+ })
121
+
122
+ function fieldKeys() {
123
+ return Object.keys(fields.value)
124
+ }
125
+
126
+ function rebaseline(source: Record<string, unknown> | null) {
127
+ const next = initialValues(fields.value)
128
+ if (source) {
129
+ for (const k of fieldKeys()) {
130
+ const col = jsKey(k, fields.value[k]!)
131
+ if (col in source) next[k] = source[col]
132
+ }
133
+ }
134
+ // `content` is a synthesized blocks column, not a def.field — round-trip it explicitly.
135
+ if (blocksEnabled.value) next.content = (source?.content as unknown[]) ?? []
136
+ // `path` (the page slug) is a pageLike system column, likewise round-tripped explicitly.
137
+ if (pageLike.value) next.path = (source?.path as string | null | undefined) ?? ''
138
+ // `seo` is a JSON system column; default to an empty object so the editor can fill it in.
139
+ if (hasSeo.value) next.seo = (source?.seo as Record<string, unknown> | undefined) ?? {}
140
+ // `status` is a system column; a new record defaults to 'draft' (unpublished) — matches the DB default.
141
+ if (hasStatus.value) next.status = (source?.status as string | undefined) ?? 'draft'
142
+ for (const k of Object.keys(next)) values[k] = next[k]
143
+ baseline.value = snapshot(values)
144
+ // The optimistic-concurrency baseline: the `updatedAt` this load/save established, sent back on the
145
+ // next save so a stale tab (loaded before someone else saved) is refused with 409 instead of
146
+ // silently reverting their change. undefined for a never-saved new record.
147
+ const stamp = source?.updatedAt
148
+ baseUpdatedAt.value = stamp != null ? new Date(stamp as string | number).getTime() : undefined
149
+ // A fresh load or a save establishes a new baseline — history does not span it.
150
+ past.value = []
151
+ future.value = []
152
+ coalesceKey = ''
153
+ }
154
+
155
+ async function init() {
156
+ const all = await useCollections().load()
157
+ const schema = all.find((c) => c.name === collection)
158
+ if (!schema) throw createError({ statusCode: 404, statusMessage: `Unknown collection: ${collection}` })
159
+ fields.value = schema.fields
160
+ fieldLayout.value = schema.fieldLayout
161
+ mode.value = schema.mode
162
+ translatable.value = schema.translatable
163
+ blocksEnabled.value = schema.blocks?.enabled ?? false
164
+ blocksAllowed.value = schema.blocks?.allowed
165
+ pageLike.value = schema.pageLike ?? false
166
+ hasSeo.value = schema.seo ?? false
167
+ hasStatus.value = schema.status ?? false
168
+ editorType.value = (schema as { editor?: string }).editor ?? (schema.blocks?.enabled ? 'blocks' : 'fields')
169
+
170
+ let row: Record<string, unknown> | null = null
171
+ if (mode.value === 'single') {
172
+ row = await $fetch<Record<string, unknown> | null>(`/api/${collection}`, translatable.value ? { query: { locale: requestedLocale } } : {})
173
+ activeLocale.value = requestedLocale
174
+ } else if (id !== 'new') {
175
+ row = await $fetch<Record<string, unknown>>(`/api/${collection}/${id}`)
176
+ if (translatable.value) {
177
+ if (typeof row?.locale === 'string') activeLocale.value = row.locale
178
+ if (typeof row?.translationGroup === 'string') translationGroup.value = row.translationGroup
179
+ // Supplementary (drives the LocaleBar) — never block editing if it fails.
180
+ try {
181
+ translations.value = await $fetch<Record<string, number | null>>(`/api/${collection}/${id}/translations`)
182
+ } catch {
183
+ translations.value = {}
184
+ }
185
+ }
186
+ } else {
187
+ activeLocale.value = requestedLocale
188
+ // A new sibling translation has no id yet, so its locale→id map is resolved by GROUP. Without it the
189
+ // LocaleBar shows every already-translated locale as a "+" create link: the copy-from source is
190
+ // unreachable, and following that link saves a second row for an occupied locale — a duplicate-locale
191
+ // 409 the user can't resolve. Supplementary (drives the LocaleBar) — never block editing if it fails.
192
+ if (translatable.value && translationGroup.value) {
193
+ try {
194
+ translations.value = await $fetch<Record<string, number | null>>(`/api/${collection}/translations`, { query: { group: translationGroup.value } })
195
+ } catch {
196
+ translations.value = {}
197
+ }
198
+ }
199
+ }
200
+ rebaseline(row)
201
+
202
+ // Supplementary dead-reference map (the editor warnings) — for any saved record, never blocks editing.
203
+ const recordId = typeof row?.id === 'number' ? row.id : null
204
+ if (recordId != null) {
205
+ try {
206
+ deadRefs.value = await $fetch<DeadRef[]>(`/api/${collection}/${recordId}/dead-refs`)
207
+ } catch {
208
+ deadRefs.value = []
209
+ }
210
+ } else {
211
+ deadRefs.value = [] // a new (unsaved) record has no references — keep it explicit, never stale
212
+ }
213
+ }
214
+
215
+ // `coalesceAs` lets a caller opt out of the default same-name coalescing: block-tree structural ops
216
+ // (remove/move/duplicate/add) all round-trip through setField('content', …) like a text edit does, so
217
+ // without a distinct key a delete landing inside another edit's 600ms burst would merge into it and
218
+ // become impossible to undo on its own.
219
+ function setField(name: string, value: unknown, coalesceAs: string = name) {
220
+ // Capture the pre-edit state for undo (coalesced for a same-field typing burst).
221
+ recordHistory(coalesceAs)
222
+ // Reconcile before overwriting: keep block errors through a reorder, drop them on an edit.
223
+ if (name === 'content') {
224
+ blockErrors.value = reconcileBlockErrors(blockErrors.value, values.content as unknown[], value as unknown[])
225
+ }
226
+ values[name] = value
227
+ formError.value = ''
228
+ const field = fields.value[name]
229
+ if (field) errors[name] = isFieldVisible(field, values) ? (validateField(asFieldDef(field), value) ?? '') : ''
230
+ // A controller edit can hide sibling fields — drop their now-irrelevant advisory errors so a
231
+ // hidden required field never shows a phantom error or blocks the save.
232
+ for (const [n, f] of Object.entries(fields.value)) if (!isFieldVisible(f, values)) errors[n] = ''
233
+ }
234
+
235
+ /** Copy another locale's field/content values into this form (the LocaleBar "copy" action). Goes
236
+ * through setField so validation, block-error reconcile, and dirty-tracking all apply. */
237
+ function applyFrom(source: Record<string, unknown>) {
238
+ for (const k of fieldKeys()) {
239
+ const col = jsKey(k, fields.value[k]!)
240
+ if (col in source) setField(k, source[col])
241
+ }
242
+ if (blocksEnabled.value && 'content' in source) setField('content', source.content)
243
+ }
244
+
245
+ function validateAll(): boolean {
246
+ let ok = true
247
+ for (const [name, field] of Object.entries(fields.value)) {
248
+ // A hidden field is exempt from validation (its `required` only applies when shown) — mirrors
249
+ // the server's required-when-visible enforcement, so the client never blocks a valid save.
250
+ if (!isFieldVisible(field, values)) { errors[name] = ''; continue }
251
+ const msg = validateField(asFieldDef(field), values[name])
252
+ errors[name] = msg ?? ''
253
+ if (msg) ok = false
254
+ }
255
+ return ok
256
+ }
257
+
258
+ function buildBody(): Record<string, unknown> {
259
+ const body: Record<string, unknown> = {}
260
+ for (const k of fieldKeys()) body[jsKey(k, fields.value[k]!)] = values[k]
261
+ if (blocksEnabled.value) body.content = values.content
262
+ // Send the slug as the routable path; a blank slug clears the route (stored as null, not "").
263
+ if (pageLike.value) body.path = (values.path as string) ? values.path : null
264
+ if (hasSeo.value) body.seo = values.seo ?? {}
265
+ if (hasStatus.value) body.status = (values.status as string) ?? 'draft'
266
+ // A new translatable multi record carries its locale, and links to a translation group when it is
267
+ // a sibling translation. Existing rows keep their locale; the singleton uses the ?locale query.
268
+ if (translatable.value && mode.value === 'multi' && id === 'new') {
269
+ body.locale = activeLocale.value
270
+ if (translationGroup.value) body.translationGroup = translationGroup.value
271
+ }
272
+ return body
273
+ }
274
+
275
+ function handleError(e: unknown) {
276
+ const { statusCode, statusMessage, issues } = readFetchError(e)
277
+ if (statusCode === 400) {
278
+ const mapped = mapServerErrors(issues)
279
+ for (const [k, msg] of Object.entries(mapped.fields)) errors[k] = msg
280
+ // `content` errors are nested (content[i].props.x) and have no flat field to render them,
281
+ // so surface a form-level banner instead of failing silently.
282
+ blockErrors.value = parseBlockErrors(issues, values.content as unknown[])
283
+ // A field error (e.g. the page slug) renders inline in PageFields, but that pane is unmounted while a
284
+ // block is selected — so also raise a banner whenever there are field errors, never a silent failure.
285
+ formError.value = mapped.form
286
+ ?? (mapped.fields.content
287
+ ? t('editor.fixBlockContent')
288
+ : Object.keys(mapped.fields).length ? t('editor.fixPageFields') : '')
289
+ } else if (statusCode === 409) {
290
+ formError.value = statusMessage ?? t('editor.saveConflict')
291
+ } else {
292
+ formError.value = statusMessage ?? t('editor.saveFailed')
293
+ }
294
+ }
295
+
296
+ async function submit(): Promise<SubmitResult> {
297
+ formError.value = ''
298
+ blockErrors.value = {}
299
+ // Clear the whole map, not just declared-field errors: `path`/`seo`/`status` are system columns
300
+ // (never in `fields.value`), so a stale server error on one of them would otherwise survive a
301
+ // later successful save. `validateAll` below repopulates every declared-field error fresh.
302
+ for (const k of Object.keys(errors)) delete errors[k]
303
+ // Client-side validation failed: surface the same banner the server path raises, so the failure is
304
+ // never silent — the offending field's inline error can sit in an unmounted pane (blocks editor).
305
+ if (!validateAll()) {
306
+ formError.value = t('editor.fixPageFields')
307
+ return { ok: false }
308
+ }
309
+ saving.value = true
310
+ try {
311
+ const body = buildBody()
312
+ // The optimistic-concurrency precondition: send the baseline `updatedAt` so a stale overwrite 409s
313
+ // instead of silently reverting a concurrent save. Omitted for a brand-new record (no baseline).
314
+ const ifUnmodified: Record<string, string> = baseUpdatedAt.value !== undefined ? { 'x-kestrel-if-unmodified-since': String(baseUpdatedAt.value) } : {}
315
+ // `method` cast: Nuxt's typed `$fetch` over-constrains the method for this dynamic (template-literal)
316
+ // admin route to GET; the runtime value is correct, only the static route type can't express it.
317
+ const row = mode.value === 'single'
318
+ ? await $fetch(`/api/${collection}`, { method: 'PUT' as never, body, headers: ifUnmodified, ...(translatable.value ? { query: { locale: activeLocale.value } } : {}) })
319
+ : id === 'new'
320
+ ? await $fetch(`/api/${collection}`, { method: 'POST' as never, body })
321
+ : await $fetch(`/api/${collection}/${id}`, { method: 'PATCH', body, headers: ifUnmodified })
322
+ rebaseline(row as Record<string, unknown>)
323
+ // `record` is the saved entity at the editor's public boundary; `row` stays the DB-layer term.
324
+ return { ok: true, record: row }
325
+ } catch (e) {
326
+ handleError(e)
327
+ return { ok: false }
328
+ } finally {
329
+ saving.value = false
330
+ }
331
+ }
332
+
333
+ const ready = init()
334
+
335
+ return {
336
+ collection,
337
+ locale: activeLocale,
338
+ translations,
339
+ deadRefs,
340
+ translationGroup,
341
+ applyFrom,
342
+ mode,
343
+ fields,
344
+ fieldLayout,
345
+ renderableFields,
346
+ translatable,
347
+ blocksEnabled,
348
+ blocksAllowed,
349
+ editorType,
350
+ pageLike,
351
+ hasSeo,
352
+ hasStatus,
353
+ values,
354
+ errors,
355
+ formError,
356
+ blockErrors,
357
+ dirty,
358
+ savedStatus,
359
+ savedPath,
360
+ saving,
361
+ ready,
362
+ setField,
363
+ validateAll,
364
+ submit,
365
+ undo,
366
+ redo,
367
+ canUndo,
368
+ canRedo,
369
+ }
370
+ }
@@ -0,0 +1,40 @@
1
+ import { reactive } from 'vue'
2
+
3
+ /**
4
+ * Batched, cached client resolution of internal-link refs (`{collection, id}`) → public href,
5
+ * mirroring the server `resolveInternalHref` so the live preview's `KestrelLink` renders real internal
6
+ * paths instead of `'#'`. Per instance (no shared module state → SSR-safe). No locale param: an
7
+ * internal link points to one specific record, whose href is its own locale's path. Misses are cached
8
+ * (empty string) so a dangling (missing-target) ref isn't re-fetched on every preview recompute.
9
+ */
10
+ export function useLinkResolver() {
11
+ const cache = reactive(new Map<string, string>())
12
+ const pending = new Set<string>()
13
+ const keyFor = (collection: string, id: number) => `${collection}:${id}`
14
+
15
+ async function ensure(refs: { collection: string; id: number }[]): Promise<void> {
16
+ const missing = refs.filter((r) =>
17
+ !!r.collection && Number.isInteger(r.id) && r.id > 0
18
+ && !cache.has(keyFor(r.collection, r.id)) && !pending.has(keyFor(r.collection, r.id)))
19
+ if (!missing.length) return
20
+ const keys = missing.map((r) => keyFor(r.collection, r.id))
21
+ for (const k of keys) pending.add(k)
22
+ try {
23
+ const { data } = await $fetch<{ data: { collection: string; id: number; href: string }[] }>(
24
+ '/api/links/resolve', { query: { refs: keys.join(',') } },
25
+ )
26
+ const got = new Map(data.map((d) => [keyFor(d.collection, d.id), d.href]))
27
+ for (const k of keys) cache.set(k, got.get(k) ?? '') // cache misses too, as '' → no re-fetch
28
+ } catch {
29
+ // leave unresolved — the preview shows '#' for these until a later attempt
30
+ } finally {
31
+ for (const k of keys) pending.delete(k)
32
+ }
33
+ }
34
+
35
+ function resolve(collection: string, id: number): string | null {
36
+ return cache.get(keyFor(collection, id)) || null
37
+ }
38
+
39
+ return { cache, ensure, resolve }
40
+ }
@@ -0,0 +1,53 @@
1
+ import type { Ref } from 'vue'
2
+ import type { SerializedCollection } from '../../../core/server/utils/serialize-collection'
3
+ import { availableColumns, defaultVisibleKeys, resolveVisibleColumns, type ListColumn } from '../utils/list-columns'
4
+
5
+ /**
6
+ * Per-collection list column visibility, persisted in a cookie (SSR-safe, like the rail-collapsed state).
7
+ * The store maps collection name → the chosen visible keys; an absent entry falls back to the default set.
8
+ * Stale keys (a since-removed field) are dropped on read via `resolveVisibleColumns`.
9
+ */
10
+ export function useListColumns(schema: Ref<SerializedCollection>) {
11
+ const store = useCookie<Record<string, string[]>>('kestrel-list-columns', { default: () => ({}) })
12
+
13
+ const available = computed<ListColumn[]>(() => availableColumns(schema.value))
14
+ const defaults = computed<string[]>(() => defaultVisibleKeys(schema.value))
15
+
16
+ const visibleKeys = computed<string[]>(() => {
17
+ const stored = store.value[schema.value.name]
18
+ // Project through the available columns so order is canonical and removed columns disappear.
19
+ return resolveVisibleColumns(available.value, stored ?? defaults.value).map((c) => c.key)
20
+ })
21
+ const visibleColumns = computed<ListColumn[]>(() => resolveVisibleColumns(available.value, visibleKeys.value))
22
+
23
+ function persist(keys: string[]) {
24
+ // Keep canonical order so persisted prefs round-trip stably.
25
+ const ordered = available.value.filter((c) => keys.includes(c.key)).map((c) => c.key)
26
+ store.value = { ...store.value, [schema.value.name]: ordered }
27
+ }
28
+
29
+ function toggle(key: string) {
30
+ const next = new Set(visibleKeys.value)
31
+ if (next.has(key)) {
32
+ if (next.size <= 1) return // never hide the last column
33
+ next.delete(key)
34
+ } else {
35
+ next.add(key)
36
+ }
37
+ persist([...next])
38
+ }
39
+
40
+ function reset() {
41
+ const next = { ...store.value }
42
+ delete next[schema.value.name]
43
+ store.value = next
44
+ }
45
+
46
+ const isDefault = computed(() => {
47
+ const a = visibleKeys.value
48
+ const b = defaults.value
49
+ return a.length === b.length && a.every((k, i) => k === b[i])
50
+ })
51
+
52
+ return { available, visibleColumns, visibleKeys, toggle, reset, isDefault }
53
+ }
@@ -0,0 +1,53 @@
1
+ import type { Ref } from 'vue'
2
+ import type { ListColumn } from '../utils/list-columns'
3
+ import { toQuery, toggleSort, parseListQuery, type FilterCell, type ListState } from '../utils/list-query'
4
+ import { clampPerPage, DEFAULT_PER_PAGE } from '../../../core/app/utils/list-limits'
5
+
6
+ /**
7
+ * The URL is the SINGLE SOURCE OF TRUTH for a collection list's committed state (sort · page · perPage ·
8
+ * filter). This composable derives that state from `route.query` (sanitized via `parseListQuery`, so a
9
+ * hostile/partial URL degrades to defaults rather than reaching the server), and turns each mutation into a
10
+ * router navigation: `page` uses `push` (Back walks pages), everything else uses `replace` (typing / toggling
11
+ * must not spam history). Every non-page action also resets `page → 1`, so a page-only transition is the only
12
+ * one that ever differs by page alone — the push/replace split is exact.
13
+ *
14
+ * The per-page cookie is a DEFAULT, never state: when the URL carries no `perPage`, the initial value is
15
+ * seeded from the cookie (clamped); when perPage changes we write the cookie so a chosen density survives
16
+ * navigation. The URL always wins.
17
+ */
18
+ export function useListUrlState(columns: Ref<ListColumn[]>) {
19
+ const route = useRoute()
20
+ const router = useRouter()
21
+ const perPageCookie = useCookie<number>('kestrel-list-per-page', { default: () => DEFAULT_PER_PAGE })
22
+
23
+ const parsed = computed(() => parseListQuery(route.query, columns.value))
24
+ const sort = computed(() => parsed.value.sort ?? '-createdAt')
25
+ const page = computed(() => parsed.value.page ?? 1)
26
+ const perPage = computed(() => parsed.value.perPage ?? clampPerPage(perPageCookie.value)) // URL wins, else cookie
27
+ const filter = computed(() => parsed.value.filter ?? {})
28
+ // The exact `$fetch` query the list endpoint expects — always the SANITIZED computeds, never the raw URL.
29
+ const effectiveQuery = computed(() => toQuery({ sort: sort.value, page: page.value, perPage: perPage.value, filter: filter.value }))
30
+
31
+ // We replace the WHOLE query on every navigation, so re-merge `locale` from the RAW URL (it lives outside
32
+ // the list-state keys and must survive a sort/page/filter change). Reading `route.query.locale` — NOT a
33
+ // resolved prop — keeps a locale-LESS URL locale-less after a list mutation: for a translatable collection
34
+ // the prop is the PRIMARY locale even when the URL carries no `?locale`, so merging it would inject
35
+ // `locale=<primary>`, flip the host page's `${collection}::${locale}` key and needlessly REMOUNT the list
36
+ // mid-edit. The locale switcher still navigates with an explicit `?locale`, which is preserved here (and
37
+ // correctly re-keys → remounts on a real locale switch).
38
+ function go(patch: Partial<ListState>, mode: 'push' | 'replace') {
39
+ const next: ListState = { sort: sort.value, page: page.value, perPage: perPage.value, filter: filter.value, ...patch }
40
+ const locale = route.query.locale
41
+ return router[mode]({ query: { ...toQuery(next), ...(typeof locale === 'string' ? { locale } : {}) } })
42
+ }
43
+
44
+ const setSort = (f: string) => go({ sort: toggleSort(sort.value, f), page: 1 }, 'replace')
45
+ const setPage = (p: number) => go({ page: p }, 'push')
46
+ // Corrective clamp (a page that emptied out from under us) — REPLACE, not push, so the now-invalid page
47
+ // doesn't stay a reachable history entry that Back bounces the user forward from on every press.
48
+ const clampPage = (p: number) => go({ page: p }, 'replace')
49
+ const setPerPage = (n: number) => { perPageCookie.value = clampPerPage(n); return go({ perPage: clampPerPage(n), page: 1 }, 'replace') }
50
+ const setFilter = (f: Record<string, FilterCell>) => go({ filter: f, page: 1 }, 'replace')
51
+
52
+ return { sort, page, perPage, filter, effectiveQuery, setSort, setPage, clampPage, setPerPage, setFilter }
53
+ }
@@ -0,0 +1,15 @@
1
+ import { ref } from 'vue'
2
+
3
+ // A create saves, then the host navigates the editor to the new record's own URL (`/new` → `/<id>`), which
4
+ // tears down the editor that issued the save and remounts a fresh one at the real id. The republish it
5
+ // kicked off is still in flight, so the ARRIVING editor must POLL the live status (not do a single refresh)
6
+ // to catch the "Generating…" → "Live" transition. This module-scoped flag carries that intent across the
7
+ // remount: the departing instance sets it on save, the arriving instance reads-and-clears it on mount.
8
+ // Client-only ephemeral signal (set in a user save handler, read on mount) — a plain ref is enough; it never
9
+ // runs during SSR, so there is no cross-request leakage.
10
+ const pending = ref(false)
11
+
12
+ /** Shared "the next editor mount should poll the live status" flag — see the note above. */
13
+ export function usePendingPublishPoll() {
14
+ return pending
15
+ }
@@ -0,0 +1,30 @@
1
+ import { computed } from 'vue'
2
+ import type { Dim } from '../utils/preview-viewport'
3
+
4
+ /**
5
+ * Persisted page-builder preview viewport: the target width/height as `Dim`s (a fixed px, or `'auto'`
6
+ * which fills the pane). Cookie-backed so the choice survives reloads and stays SSR-safe (server + client
7
+ * read the same value — no hydration mismatch), the same pattern as `useRailCollapsed`. Defaults to the
8
+ * config-driven desktop width with an `'auto'` height (fills the pane, no container scroll).
9
+ *
10
+ * Each setter reassigns the whole object (rather than mutating a field) so Nuxt's cookie watcher always
11
+ * re-serialises. Note: the seeded default only applies when the cookie is absent — if a project later
12
+ * changes `preview.desktopWidth`, an existing cookie keeps its `w` until the Desktop preset is clicked. A
13
+ * stale cookie from before Auto height (a numeric `h`, plus a now-ignored `fit`) is tolerated: it just
14
+ * reads as a fixed custom height until Desktop is re-selected.
15
+ */
16
+ export interface PreviewViewport {
17
+ w: Dim
18
+ h: Dim
19
+ }
20
+
21
+ export function usePreviewViewport(desktopWidth: number) {
22
+ const state = useCookie<PreviewViewport>('kestrel-preview-viewport', {
23
+ default: () => ({ w: desktopWidth, h: 'auto' }),
24
+ })
25
+
26
+ const width = computed({ get: () => state.value.w, set: (v: Dim) => { state.value = { ...state.value, w: v } } })
27
+ const height = computed({ get: () => state.value.h, set: (v: Dim) => { state.value = { ...state.value, h: v } } })
28
+
29
+ return { width, height }
30
+ }
@@ -0,0 +1,98 @@
1
+ import { ref, toValue } from 'vue'
2
+ import type { MaybeRefOrGetter } from 'vue'
3
+
4
+ /** The live publish state of a record's static page, as returned by `GET /api/publish-status`. */
5
+ export interface PublishStatusData {
6
+ route: string | null
7
+ status: 'success' | 'error' | null
8
+ error?: string | null
9
+ updatedAt?: string | null
10
+ /** Which output the last attempt wrote to — `'local'` dir or `'s3'` bucket; null when no row yet. */
11
+ target?: 'local' | 's3' | null
12
+ /** Whether the runtime publisher actually produces files in THIS environment (prod + `output.auto`).
13
+ * `false` in dev / with static output off → a page can never turn "Live" here, so don't poll for it. */
14
+ generates?: boolean
15
+ /** The configured output destination (where the NEXT publish would go), independent of any row. */
16
+ driver?: 'local' | 's3'
17
+ }
18
+
19
+ const EMPTY: PublishStatusData = { route: null, status: null }
20
+
21
+ /**
22
+ * Fetch (and refetch) the LIVE / generated publish state of the record the editor is on, for the right
23
+ * dot of the editor Ampel. Explicit `refresh()` (no auto-fetch) so it is testable in isolation and the
24
+ * caller controls timing: the editor refreshes on mount and again after each save (a save may (re)publish
25
+ * the page). Skips the request entirely when disabled (non-pageLike collection) or the record is unsaved
26
+ * (`id === 'new'` has no server row, hence no route yet). A failed fetch degrades to an empty status
27
+ * rather than surfacing an error — the ampel is a non-critical signal.
28
+ */
29
+ export function usePublishStatus(args: {
30
+ collection: MaybeRefOrGetter<string>
31
+ id: MaybeRefOrGetter<string | number>
32
+ locale?: MaybeRefOrGetter<string | undefined>
33
+ enabled?: MaybeRefOrGetter<boolean>
34
+ }) {
35
+ const data = ref<PublishStatusData>({ ...EMPTY })
36
+ // Bumped by every `refresh()` / `refreshUntilSettled()` start; an in-flight poll compares the token it
37
+ // captured and bails the moment a newer call supersedes it (navigation, another save), so stale loops
38
+ // can't keep writing to `data`.
39
+ let pollToken = 0
40
+
41
+ async function fetchOnce(): Promise<void> {
42
+ // Guard every write by the token captured at call time: if a newer refresh/poll supersedes this one
43
+ // while its request is in flight, its (possibly stale) response must NOT overwrite the fresher data —
44
+ // two overlapping fetches (a rapid re-save superseding a poll) can otherwise resolve out of order.
45
+ const token = pollToken
46
+ const enabled = args.enabled === undefined ? true : toValue(args.enabled)
47
+ const id = toValue(args.id)
48
+ if (!enabled || id === 'new' || id == null) {
49
+ if (token === pollToken) data.value = { ...EMPTY }
50
+ return
51
+ }
52
+ try {
53
+ const res = await $fetch<PublishStatusData>('/api/publish-status', {
54
+ query: { collection: toValue(args.collection), id, locale: toValue(args.locale) },
55
+ })
56
+ if (token === pollToken) data.value = res
57
+ } catch {
58
+ if (token === pollToken) data.value = { ...EMPTY }
59
+ }
60
+ }
61
+
62
+ /** One-shot fetch. Also cancels any running poll (this is the fresh source of truth). */
63
+ async function refresh(): Promise<void> {
64
+ pollToken++
65
+ await fetchOnce()
66
+ }
67
+
68
+ /**
69
+ * Poll the live status until it SETTLES, for the right lamp after a save: in prod a save enqueues an
70
+ * async (debounced) republish that finishes a moment later, so a single refresh would only ever catch the
71
+ * pre-republish row. Stops when the record has no route, the environment doesn't generate (`generates ===
72
+ * false` — dev / static output off: nothing will ever be produced, so polling is pointless), or a terminal
73
+ * outcome (`success`/`error`) from the NEW republish lands.
74
+ *
75
+ * `since` is the row's `updatedAt` BEFORE this save. `publish_status` is a latest-state upsert that is NOT
76
+ * cleared first, so a re-saved page keeps its prior success/error row until the fresh render rewrites it —
77
+ * without this baseline the very first poll would read that stale row and settle instantly (e.g. staying
78
+ * green "Live" for a re-save that actually failed to republish). So a terminal outcome only counts as
79
+ * settled once its `updatedAt` differs from `since`. Bounded by `attempts`; superseded-safe via `pollToken`.
80
+ */
81
+ async function refreshUntilSettled(opts?: { attempts?: number; delayMs?: number; since?: string | null }): Promise<void> {
82
+ const token = ++pollToken
83
+ const attempts = opts?.attempts ?? 12
84
+ const delayMs = opts?.delayMs ?? 2000
85
+ const since = opts?.since ?? null
86
+ for (let i = 0; i < attempts; i++) {
87
+ if (token !== pollToken) return
88
+ await fetchOnce()
89
+ if (token !== pollToken) return
90
+ const d = data.value
91
+ const settled = (d.status === 'success' || d.status === 'error') && (d.updatedAt ?? null) !== since
92
+ if (!d.route || d.generates === false || settled) return
93
+ if (i < attempts - 1) await new Promise((resolve) => setTimeout(resolve, delayMs))
94
+ }
95
+ }
96
+
97
+ return { data, refresh, refreshUntilSettled }
98
+ }