@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,39 @@
1
+ import { ref } from 'vue'
2
+ import type { OpItem, RelocationReport } from '../utils/ops'
3
+
4
+ type Strategy = 'abort' | 'skip' | 'overwrite' | 'rename'
5
+
6
+ export function useMediaRelocate(onChanged?: () => void) {
7
+ const { t } = useT()
8
+ const busy = ref(false)
9
+ const error = ref<string | null>(null)
10
+
11
+ async function preview(type: 'move' | 'copy', items: OpItem[], dest: string): Promise<RelocationReport> {
12
+ error.value = null
13
+ try {
14
+ return await $fetch<RelocationReport>(`/api/media/${type}`, { method: 'POST', body: { items, dest, dryRun: true } })
15
+ } catch (e) {
16
+ error.value = (e as { statusMessage?: string })?.statusMessage ?? t('media.error.relocate')
17
+ throw e
18
+ }
19
+ }
20
+
21
+ async function execute(type: 'move' | 'copy', items: OpItem[], dest: string, onConflict: Strategy): Promise<void> {
22
+ busy.value = true
23
+ error.value = null
24
+ try {
25
+ await $fetch(`/api/media/${type}`, { method: 'POST', body: { items, dest, onConflict } })
26
+ onChanged?.()
27
+ } catch (e) {
28
+ const err = e as { statusCode?: number; statusMessage?: string }
29
+ error.value = err.statusCode === 409
30
+ ? t('media.error.relocateConflict')
31
+ : err.statusMessage ?? t('media.error.relocate')
32
+ throw e
33
+ } finally {
34
+ busy.value = false
35
+ }
36
+ }
37
+
38
+ return { busy, error, preview, execute }
39
+ }
@@ -0,0 +1,50 @@
1
+ import { reactive, toValue } from 'vue'
2
+ import type { MaybeRefOrGetter } from 'vue'
3
+ import type { ResolvedMedia } from '../../server/utils/resolve'
4
+
5
+ // The client-facing resolved-media shape (`GET /api/media/resolve`) is the server resolver's own
6
+ // return type — re-exported so consumers keep importing it from here, single-sourced so it can't drift.
7
+ export type { ResolvedMedia }
8
+
9
+ /**
10
+ * Batched, cached client resolution of media ids → `ResolvedMedia`. A reactive cache so callers can
11
+ * `resolve(id)` inside a computed and have it re-evaluate once `ensure()` fills the cache. Per
12
+ * instance (no shared module state → SSR-safe). The cache is keyed per locale, and `locale` may be
13
+ * a ref/getter — a locale change makes its ids "missing" again so `ensure()` re-fetches and
14
+ * `resolve()` returns the new-locale entry (translations differ per locale).
15
+ */
16
+ export function useMediaResolver(locale?: MaybeRefOrGetter<string>) {
17
+ const cache = reactive(new Map<string, ResolvedMedia>())
18
+ // Keys that resolved to NOTHING (deleted/dangling id). Without this, a missing id is "missing" forever and
19
+ // re-requested on every ensure(); the negative cache stops re-fetching it. Keyed per (locale, id) like the
20
+ // positive cache, so a locale change still re-resolves. Only populated on a SUCCESSFUL fetch that omitted
21
+ // the id — a network error leaves the id retryable.
22
+ const absent = new Set<string>()
23
+ const pending = new Set<string>()
24
+ const loc = () => toValue(locale) || 'en'
25
+ const keyFor = (l: string, id: number) => `${l}:${id}`
26
+
27
+ async function ensure(ids: number[]): Promise<void> {
28
+ const l = loc()
29
+ const missing = ids.filter((id) => Number.isInteger(id) && id > 0 && !cache.has(keyFor(l, id)) && !absent.has(keyFor(l, id)) && !pending.has(keyFor(l, id)))
30
+ if (!missing.length) return
31
+ for (const id of missing) pending.add(keyFor(l, id))
32
+ try {
33
+ const { data } = await $fetch<{ data: ResolvedMedia[] }>('/api/media/resolve', { query: { ids: missing.join(','), locale: l } })
34
+ const returned = new Set<number>()
35
+ for (const m of data) { cache.set(keyFor(l, m.id), m); returned.add(m.id) }
36
+ // A requested id the server didn't return is gone → remember it so we don't keep asking.
37
+ for (const id of missing) if (!returned.has(id)) absent.add(keyFor(l, id))
38
+ } catch {
39
+ // leave unresolved (NOT absent) — a transient error stays retryable on a later attempt
40
+ } finally {
41
+ for (const id of missing) pending.delete(keyFor(l, id))
42
+ }
43
+ }
44
+
45
+ function resolve(id: number): ResolvedMedia | null {
46
+ return cache.get(keyFor(loc(), id)) ?? null
47
+ }
48
+
49
+ return { cache, ensure, resolve }
50
+ }
@@ -0,0 +1,93 @@
1
+ import { ref, computed } from 'vue'
2
+ import type { PendingUpload } from '../utils/dnd'
3
+
4
+ export interface UploadItem {
5
+ id: string; file: File; filename: string; folder: string
6
+ status: 'queued' | 'uploading' | 'done' | 'error' | 'conflict' | 'skipped'
7
+ message?: string; suggestion?: string; existingId?: number
8
+ }
9
+
10
+ export interface UploadCallbacks {
11
+ /** Fired once a batch has fully settled (no active uploads, no unresolved conflicts). */
12
+ onSettled?: () => void
13
+ /** Fired for each item that fails (non-conflict). The component surfaces the reason (`item.message`). */
14
+ onError?: (item: UploadItem) => void
15
+ }
16
+
17
+ export function useMediaUpload(cb: UploadCallbacks = {}) {
18
+ const queue = ref<UploadItem[]>([])
19
+ let seq = 0
20
+ const conflicts = computed(() => queue.value.filter((i) => i.status === 'conflict'))
21
+ const active = computed(() => queue.value.some((i) => i.status === 'queued' || i.status === 'uploading'))
22
+ const counts = computed(() => ({
23
+ total: queue.value.length,
24
+ done: queue.value.filter((i) => i.status === 'done').length,
25
+ error: queue.value.filter((i) => i.status === 'error').length,
26
+ conflict: conflicts.value.length,
27
+ skipped: queue.value.filter((i) => i.status === 'skipped').length,
28
+ }))
29
+
30
+ async function uploadItem(item: UploadItem, opts: { overwrite?: boolean; filename?: string } = {}) {
31
+ item.status = 'uploading'
32
+ const fd = new FormData()
33
+ fd.append('file', item.file)
34
+ if (item.folder) fd.append('folder', item.folder)
35
+ fd.append('filename', opts.filename ?? item.filename)
36
+ if (opts.overwrite) fd.append('overwrite', 'true')
37
+ try {
38
+ await $fetch('/api/media', { method: 'POST', body: fd })
39
+ item.status = 'done'
40
+ } catch (e) {
41
+ type ConflictData = { suggestion?: string; existingId?: number }
42
+ const err = e as { statusCode?: number; statusMessage?: string; data?: ConflictData & { statusMessage?: string; data?: ConflictData } }
43
+ if (err.statusCode === 409) {
44
+ // $fetch wraps the thrown H3 error: the original payload is nested under data.data over HTTP.
45
+ const payload = err.data?.data ?? err.data
46
+ item.status = 'conflict'; item.suggestion = payload?.suggestion; item.existingId = payload?.existingId
47
+ } else if (err.statusCode === 401) {
48
+ // A mid-session 401 is owned by the re-auth interceptor (it redirects to login) — don't also
49
+ // raise a per-item toast for the same event.
50
+ item.status = 'error'
51
+ } else {
52
+ // Prefer the server reason from the JSON body (transport-independent) over `statusMessage`,
53
+ // which mirrors `response.statusText` — blank on HTTP/2 and rewritten by proxies. The component
54
+ // localizes a fallback when neither is present.
55
+ item.status = 'error'; item.message = err.data?.statusMessage ?? err.statusMessage
56
+ cb.onError?.(item)
57
+ }
58
+ }
59
+ }
60
+
61
+ function settle() { if (!active.value && !conflicts.value.length) cb.onSettled?.() }
62
+
63
+ async function run() {
64
+ for (const item of queue.value) if (item.status === 'queued') await uploadItem(item)
65
+ settle()
66
+ }
67
+
68
+ function enqueueUploads(uploads: PendingUpload[]) {
69
+ for (const u of uploads) queue.value.push({ id: `u${++seq}`, file: u.file, filename: u.file.name, folder: u.folder, status: 'queued' })
70
+ return run()
71
+ }
72
+
73
+ function enqueue(files: File[], folder: string) {
74
+ return enqueueUploads(files.map((file) => ({ file, folder })))
75
+ }
76
+
77
+ async function resolve(id: string, action: 'overwrite' | 'rename' | 'skip', name?: string) {
78
+ const item = queue.value.find((i) => i.id === id)
79
+ if (!item || item.status !== 'conflict') return
80
+ if (action === 'skip') item.status = 'skipped'
81
+ else if (action === 'overwrite') await uploadItem(item, { overwrite: true })
82
+ else { item.filename = name ?? item.suggestion ?? item.filename; await uploadItem(item, { filename: item.filename }) }
83
+ settle()
84
+ }
85
+
86
+ async function resolveAll(action: 'overwrite' | 'rename' | 'skip') {
87
+ for (const item of [...conflicts.value]) await resolve(item.id, action, action === 'rename' ? item.suggestion : undefined)
88
+ }
89
+
90
+ function reset() { queue.value = [] }
91
+
92
+ return { queue, conflicts, active, counts, enqueue, enqueueUploads, resolve, resolveAll, reset }
93
+ }
@@ -0,0 +1,34 @@
1
+ import { computed, toValue, type MaybeRefOrGetter } from 'vue'
2
+ import type { ResolvedMedia } from '../../server/utils/resolve'
3
+ import type { ResolvedVariant } from '../../../core/server/utils/kestrel-config'
4
+ import { buildPictureModel, requestVariantSpecs, type VariantRequest, type PictureModel } from '../utils/picture-model'
5
+
6
+ /**
7
+ * Reactive `<picture>` view-model for a resolved media + a variant request. The single render-AND-declare
8
+ * site: one call may declare MULTIPLE sizes + formats. During a publish/generate SSR render it also stashes
9
+ * the call's concrete specs on the request event, so the publish `beforeResponse` hook collects them and the
10
+ * end-of-publish reconcile registers exactly what was rendered (usage-driven auto-discovery). Server-guarded,
11
+ * so it is a no-op on the client and in non-Nuxt unit mounts.
12
+ */
13
+ export function useMediaVariant(
14
+ media: MaybeRefOrGetter<ResolvedMedia | null | undefined>,
15
+ request: MaybeRefOrGetter<VariantRequest>,
16
+ opts?: { priority?: MaybeRefOrGetter<boolean> },
17
+ ) {
18
+ if (import.meta.server) {
19
+ const specs = requestVariantSpecs(toValue(request))
20
+ if (specs.length) {
21
+ const ev = useRequestEvent()
22
+ if (ev) {
23
+ const ctx = ev.context as { kestrelVariants?: ResolvedVariant[] }
24
+ ;(ctx.kestrelVariants ??= []).push(...specs)
25
+ }
26
+ }
27
+ }
28
+ const model = computed<PictureModel | null>(() => {
29
+ const m = toValue(media)
30
+ if (!m) return null
31
+ return buildPictureModel(m, toValue(request), { lazy: !toValue(opts?.priority ?? false) })
32
+ })
33
+ return { model }
34
+ }
@@ -0,0 +1,6 @@
1
+ // components are NOT auto-imported in a plugin's script context — pull FieldMedia from #components
2
+ import { FieldMedia } from '#components'
3
+
4
+ export default defineNuxtPlugin(() => {
5
+ registerFieldComponent('media', FieldMedia)
6
+ })
@@ -0,0 +1,69 @@
1
+ import { joinFolder } from './library'
2
+
3
+ export interface PendingUpload { file: File; folder: string }
4
+ export interface WalkResult { uploads: PendingUpload[]; dirs: string[] }
5
+
6
+ export interface DirectoryReaderLike {
7
+ readEntries(success: (entries: EntryLike[]) => void, error?: (e: unknown) => void): void
8
+ }
9
+ export interface EntryLike {
10
+ isFile: boolean
11
+ isDirectory: boolean
12
+ name: string
13
+ file?(success: (file: File) => void, error?: (e: unknown) => void): void
14
+ createReader?(): DirectoryReaderLike
15
+ }
16
+
17
+ /** Drain a DirectoryReader — readEntries yields in batches (Chrome caps ~100), loop until empty. */
18
+ export function readAllEntries(reader: DirectoryReaderLike): Promise<EntryLike[]> {
19
+ return new Promise((resolve, reject) => {
20
+ const all: EntryLike[] = []
21
+ const next = () => reader.readEntries((batch) => { if (!batch.length) return resolve(all); all.push(...batch); next() }, reject)
22
+ next()
23
+ })
24
+ }
25
+
26
+ function entryFile(entry: EntryLike): Promise<File> {
27
+ return new Promise((resolve, reject) => entry.file!((f) => resolve(f), reject))
28
+ }
29
+
30
+ export async function walkEntry(entry: EntryLike, base: string, out: WalkResult): Promise<void> {
31
+ if (entry.isFile) {
32
+ out.uploads.push({ file: await entryFile(entry), folder: base })
33
+ } else if (entry.isDirectory && entry.createReader) {
34
+ const dir = joinFolder(base, entry.name)
35
+ out.dirs.push(dir)
36
+ for (const child of await readAllEntries(entry.createReader())) await walkEntry(child, dir, out)
37
+ }
38
+ }
39
+
40
+ interface DataTransferLike {
41
+ items?: ArrayLike<{ webkitGetAsEntry?(): EntryLike | null }> | null
42
+ files?: ArrayLike<File> | null
43
+ }
44
+
45
+ export async function extractUploads(dt: DataTransferLike, base: string): Promise<WalkResult> {
46
+ const out: WalkResult = { uploads: [], dirs: [] }
47
+ // Grab entries SYNCHRONOUSLY before any await — the DataTransferItemList dies once the handler yields.
48
+ const entries = (dt.items ? Array.from(dt.items) : [])
49
+ .map((it) => it.webkitGetAsEntry?.() ?? null)
50
+ .filter((e): e is EntryLike => !!e)
51
+ if (entries.length) {
52
+ for (const entry of entries) await walkEntry(entry, base, out)
53
+ } else {
54
+ for (const file of Array.from(dt.files ?? [])) out.uploads.push({ file, folder: base })
55
+ }
56
+ return out
57
+ }
58
+
59
+ /** The minimal set of folders to create explicitly: dirs no uploaded file already creates
60
+ * (ensure-on-upload makes a file's whole ancestor chain), reduced to the deepest. */
61
+ export function selectEmptyFolders(uploads: PendingUpload[], dirs: string[]): string[] {
62
+ const covered = new Set<string>()
63
+ for (const u of uploads) {
64
+ const segs = u.folder.split('/').filter(Boolean)
65
+ for (let i = 1; i <= segs.length; i++) covered.add(segs.slice(0, i).join('/'))
66
+ }
67
+ const empty = dirs.filter((d) => d && !covered.has(d))
68
+ return empty.filter((d) => !empty.some((o) => o !== d && o.startsWith(`${d}/`)))
69
+ }
@@ -0,0 +1,79 @@
1
+ import type { LibraryFolder } from '../../server/utils/library'
2
+
3
+ // Re-exported from the server util that produces it (`GET /api/media/library`) so the wire shape is
4
+ // single-sourced.
5
+ export type { LibraryFolder }
6
+
7
+ export interface LibraryFile {
8
+ id: number; filename: string; mime: string; folder: string; size: number
9
+ width?: number; height?: number; thumbhash?: string; src: string; srcset?: string; alt?: string
10
+ createdAt?: string
11
+ }
12
+ export type LibraryItem =
13
+ | { type: 'folder'; folder: LibraryFolder }
14
+ | { type: 'file'; file: LibraryFile }
15
+
16
+ export function itemKey(item: LibraryItem): string {
17
+ return item.type === 'folder' ? `folder:${item.folder.path}` : `file:${item.file.id}`
18
+ }
19
+
20
+ /** Inclusive range between the anchor and target keys (by position in `orderedKeys`), unioned
21
+ * onto `current`. If the anchor is not found, selects only the target. */
22
+ export function computeRange(orderedKeys: string[], anchorKey: string, targetKey: string, current: Set<string>): Set<string> {
23
+ const out = new Set(current)
24
+ const t = orderedKeys.indexOf(targetKey)
25
+ if (t < 0) return out
26
+ const a = orderedKeys.indexOf(anchorKey)
27
+ if (a < 0) { out.add(targetKey); return out }
28
+ for (let i = Math.min(a, t); i <= Math.max(a, t); i++) out.add(orderedKeys[i])
29
+ return out
30
+ }
31
+
32
+ export function humanizeSize(bytes: number): string {
33
+ if (bytes < 1024) return `${bytes} B`
34
+ const kb = bytes / 1024
35
+ if (kb < 1024) return `${kb.toFixed(1)} KB`
36
+ return `${(kb / 1024).toFixed(1)} MB`
37
+ }
38
+
39
+ /** The parent of an internal folder path, or `null` at the root — never above it (no path traversal).
40
+ * `''` → null (root); `'a'` → `''`; `'a/b'` → `'a'`. */
41
+ export function parentFolder(folder: string): string | null {
42
+ if (folder === '') return null
43
+ const i = folder.lastIndexOf('/')
44
+ return i === -1 ? '' : folder.slice(0, i)
45
+ }
46
+
47
+ /** Split a typed folder path into the existing-parent part and the trailing fragment being typed. */
48
+ export function splitPathInput(input: string): { parent: string; fragment: string } {
49
+ const i = input.lastIndexOf('/')
50
+ return i === -1 ? { parent: '', fragment: input } : { parent: input.slice(0, i), fragment: input.slice(i + 1) }
51
+ }
52
+
53
+ /** Join folder parts into a clean relative path (drops empty segments). Also parses a typed display
54
+ * path back to the internal form: `joinFolder('/test123/')` → `'test123'`, `joinFolder('/')` → `''`. */
55
+ export function joinFolder(...parts: string[]): string {
56
+ return parts.flatMap((p) => p.split('/')).filter(Boolean).join('/')
57
+ }
58
+
59
+ /** Internal folder path → the slash-delimited path shown to the user, rooted at the media directory:
60
+ * `''` → `'/'`, `'test123'` → `'/test123/'`, `'a/b'` → `'/a/b/'`. Tolerant of stray slashes in input. */
61
+ export function displayFolderPath(folder: string): string {
62
+ const clean = joinFolder(folder)
63
+ return clean === '' ? '/' : `/${clean}/`
64
+ }
65
+
66
+ /** Longest common FOLDER of clean relative paths; '' (root) when they diverge at root or none given.
67
+ * Keeps only equal leading segments and drops empties, so it can never emit '..' or escape root. */
68
+ export function commonFolder(folders: string[]): string {
69
+ if (!folders.length) return ''
70
+ const split = folders.map((f) => f.split('/').filter(Boolean))
71
+ let prefix = split[0]!
72
+ for (const segs of split.slice(1)) {
73
+ let i = 0
74
+ while (i < prefix.length && prefix[i] === segs[i]) i++
75
+ prefix = prefix.slice(0, i)
76
+ if (!prefix.length) break
77
+ }
78
+ return prefix.join('/')
79
+ }
@@ -0,0 +1,62 @@
1
+ import type { LibraryItem } from './library'
2
+ import type { OpItem, DeleteReport } from '../../server/utils/media-ops'
3
+ import type { Conflict, RelocationReport } from '../../server/utils/relocate-ops'
4
+ import type { MediaUsage } from '../../server/utils/usages'
5
+
6
+ // Single-source the HTTP wire shapes from the server utils that own them — one definition, no drift.
7
+ export type { OpItem, DeleteReport, Conflict, RelocationReport, MediaUsage }
8
+
9
+ /** Structural shape of a UiMenu entry (UiMenu declares its own copy; kept in sync structurally). */
10
+ export interface MenuItem { label: string; value: string; danger?: boolean; disabled?: boolean }
11
+
12
+ /** A menu entry before its label is localized: carries an i18n key (+ a `count` for plural-bearing
13
+ * entries) so the pure builder stays free of presentation copy; the composable resolves it via useT. */
14
+ export interface MenuItemSpec { labelKey: string; count?: number; value: string; danger?: boolean }
15
+
16
+ export function toOpItem(item: LibraryItem): OpItem {
17
+ return item.type === 'folder' ? { type: 'folder', path: item.folder.path } : { type: 'file', id: item.file.id }
18
+ }
19
+
20
+ /** Map a right-clicked element to its LibraryItem via the data-id attributes
21
+ * (files carry data-file-id, folders carry data-drop-folder). Null if neither matches. */
22
+ export function resolveTargetItem(el: Element | null, items: LibraryItem[]): LibraryItem | null {
23
+ const fileEl = el?.closest('[data-file-id]')
24
+ if (fileEl) {
25
+ const id = Number(fileEl.getAttribute('data-file-id'))
26
+ return items.find((i) => i.type === 'file' && i.file.id === id) ?? null
27
+ }
28
+ const folderEl = el?.closest('[data-drop-folder]')
29
+ if (folderEl) {
30
+ const path = folderEl.getAttribute('data-drop-folder')
31
+ return items.find((i) => i.type === 'folder' && i.folder.path === path) ?? null
32
+ }
33
+ return null
34
+ }
35
+
36
+ /** Explorer semantics: right-clicking a selected item targets the whole selection;
37
+ * right-clicking elsewhere targets just that item. */
38
+ export function effectiveTargets(clicked: LibraryItem, isSelected: (i: LibraryItem) => boolean, selected: LibraryItem[]): LibraryItem[] {
39
+ return isSelected(clicked) ? selected : [clicked]
40
+ }
41
+
42
+ /** Build the context-menu items for the current target + clipboard state. Blank space with an
43
+ * empty clipboard returns [] (the caller suppresses the menu). */
44
+ export function buildMenuItems(ctx: { targetCount: number; targetType: 'file' | 'folder' | null; clipboardEmpty: boolean }): MenuItemSpec[] {
45
+ const { targetCount, targetType, clipboardEmpty } = ctx
46
+ if (targetCount === 0) return clipboardEmpty ? [] : [{ labelKey: 'media.menu.paste', value: 'paste' }]
47
+ if (targetCount === 1) {
48
+ const items: MenuItemSpec[] = [
49
+ { labelKey: 'media.menu.cut', value: 'cut' },
50
+ { labelKey: 'media.menu.copy', value: 'copy' },
51
+ { labelKey: 'media.menu.rename', value: 'rename' },
52
+ { labelKey: 'media.menu.delete', value: 'delete', danger: true },
53
+ ]
54
+ if (targetType === 'folder' && !clipboardEmpty) items.push({ labelKey: 'media.menu.pasteInto', value: 'paste' })
55
+ return items
56
+ }
57
+ return [
58
+ { labelKey: 'media.menu.cut', value: 'cut' },
59
+ { labelKey: 'media.menu.copy', value: 'copy' },
60
+ { labelKey: 'media.menu.deleteN', count: targetCount, value: 'delete', danger: true },
61
+ ]
62
+ }
@@ -0,0 +1,125 @@
1
+ import type { ResolvedMedia } from '../../server/utils/resolve'
2
+ import type { VariantFit, VariantFormat, ResolvedVariant } from '../../../core/server/utils/kestrel-config'
3
+ import { variantName } from './variant-name'
4
+
5
+ /** What a `KestrelImg` / `useMediaVariant` call declares it needs. One call may declare MULTIPLE sizes and
6
+ * formats: `widths` (proportional responsive set) and/or a fixed-box `crop`, plus named config `preset`s,
7
+ * crossed with `formats` (preference order, most-modern first). */
8
+ export interface VariantRequest {
9
+ widths?: number[]
10
+ crop?: { width: number; height: number; fit?: VariantFit }
11
+ preset?: string | string[]
12
+ formats?: VariantFormat[]
13
+ /** The `<img sizes>` attribute; `auto` (or `auto, <fallback>`) is honoured on lazy images (see resolveSizes). */
14
+ sizes?: string
15
+ }
16
+
17
+ export interface PictureSource { type: string; srcset: string }
18
+ export interface PictureModel {
19
+ sources: PictureSource[]
20
+ src: string
21
+ width: number | null
22
+ height: number | null
23
+ alt: string
24
+ sizes?: string
25
+ }
26
+
27
+ const MIME: Record<string, string> = { webp: 'image/webp', jpeg: 'image/jpeg', jpg: 'image/jpeg', png: 'image/png', avif: 'image/avif', gif: 'image/gif' }
28
+
29
+ /**
30
+ * `sizes=auto` lets the browser pick the srcset candidate from the image's rendered width, but ONLY on
31
+ * lazy-loaded images — on eager ones it is inert. Non-supporting browsers ignore the `auto` keyword and use
32
+ * the fallback after it, so `auto, 100vw` degrades to `100vw`. Hence: keep `auto` only when lazy (with any
33
+ * fallback), and on an eager image drop the keyword, leaving just the fallback.
34
+ */
35
+ export function resolveSizes(sizes: string | undefined, lazy: boolean): string | undefined {
36
+ if (!sizes) return undefined
37
+ const t = sizes.trim()
38
+ if (/^auto\b/i.test(t)) {
39
+ const fallback = t.slice(4).replace(/^\s*,\s*/, '').trim()
40
+ if (!lazy) return fallback || undefined
41
+ return fallback ? `auto, ${fallback}` : 'auto'
42
+ }
43
+ return t
44
+ }
45
+
46
+ /**
47
+ * The concrete, registrable specs a request declares (for the prerender scan): each `width` → a
48
+ * proportional `w<width>` spec, a `crop` → a fixed-box spec, both crossed with the request's formats.
49
+ * Named `preset`s are NOT expanded — they are already config-resolved (referenced by name, not discovered).
50
+ */
51
+ export function requestVariantSpecs(req: VariantRequest): ResolvedVariant[] {
52
+ const formats: VariantFormat[] = req.formats?.length ? req.formats : ['webp']
53
+ const out: ResolvedVariant[] = []
54
+ // props may bind strings; drop any non-positive-integer dim so a bad spec is never registered (would crash sharp later)
55
+ for (const raw of req.widths ?? []) {
56
+ const w = posInt(raw)
57
+ if (w != null) out.push({ name: variantName({ width: w }), width: w, height: null, fit: 'cover', position: 'centre', formats: [...formats] })
58
+ }
59
+ if (req.crop) {
60
+ const w = posInt(req.crop.width)
61
+ const h = posInt(req.crop.height)
62
+ if (w != null && h != null) out.push({ name: variantName({ width: w, height: h, fit: req.crop.fit }), width: w, height: h, fit: req.crop.fit ?? 'cover', position: 'centre', formats: [...formats] })
63
+ }
64
+ return out
65
+ }
66
+
67
+ function posInt(value: unknown): number | null {
68
+ const n = Math.floor(Number(value))
69
+ return Number.isFinite(n) && n >= 1 ? n : null
70
+ }
71
+
72
+ /** The variant names a request targets: derived names for widths/crop + any explicit preset names.
73
+ * Applies the SAME posInt sanitisation as requestVariantSpecs so the render-lookup name matches the
74
+ * registered/derived name — otherwise a fractional width registers `w640` but looks up `w640.5`, and the
75
+ * derivative is never used. */
76
+ export function requestedNames(req: VariantRequest): string[] {
77
+ const names: string[] = []
78
+ for (const raw of req.widths ?? []) {
79
+ const w = posInt(raw)
80
+ if (w != null) names.push(variantName({ width: w }))
81
+ }
82
+ if (req.crop) {
83
+ const w = posInt(req.crop.width)
84
+ const h = posInt(req.crop.height)
85
+ if (w != null && h != null) names.push(variantName({ width: w, height: h, fit: req.crop.fit }))
86
+ }
87
+ const presets = req.preset ? (Array.isArray(req.preset) ? req.preset : [req.preset]) : []
88
+ names.push(...presets)
89
+ return [...new Set(names)]
90
+ }
91
+
92
+ /**
93
+ * Build the `<picture>` view-model for a resolved media + a variant request: one `<source>` per requested
94
+ * format (in the request's preference order) whose srcset is the matching derivatives, and an `<img>`
95
+ * fallback = the largest derivative of the LAST (most-compatible) format, or the original when none exist.
96
+ * Pure — the load-bearing render logic, unit-tested without a DOM.
97
+ */
98
+ export function buildPictureModel(media: ResolvedMedia, req: VariantRequest, opts: { lazy: boolean }): PictureModel {
99
+ const names = new Set(requestedNames(req))
100
+ const formats = (req.formats?.length ? req.formats : ['webp']) as string[]
101
+ const pool = media.variants.filter((v) => names.has(v.name))
102
+
103
+ const sources: PictureSource[] = []
104
+ for (const fmt of formats) {
105
+ const entries = pool.filter((v) => v.format === fmt).sort((a, b) => a.width - b.width)
106
+ if (!entries.length) continue
107
+ sources.push({ type: MIME[fmt] ?? `image/${fmt}`, srcset: entries.map((v) => `${v.url} ${v.width}w`).join(', ') })
108
+ }
109
+
110
+ // <img> fallback: the largest derivative of the last (most-compatible) requested format; else the original
111
+ // (which may be a large PNG/AVIF, but is always a valid last resort).
112
+ const lastFmt = formats[formats.length - 1]
113
+ const lastEntries = pool.filter((v) => v.format === lastFmt).sort((a, b) => a.width - b.width)
114
+ const fallback = lastEntries[lastEntries.length - 1]
115
+
116
+ return {
117
+ sources,
118
+ src: fallback?.url ?? media.src,
119
+ width: fallback?.width ?? media.width,
120
+ height: fallback?.height ?? media.height,
121
+ alt: media.alt ?? '',
122
+ // Lazy images with no explicit sizes default to `auto, 100vw` — a strict upgrade over the 100vw default.
123
+ sizes: resolveSizes(req.sizes ?? (opts.lazy ? 'auto, 100vw' : undefined), opts.lazy),
124
+ }
125
+ }
@@ -0,0 +1,19 @@
1
+ import { thumbHashToDataURL } from 'thumbhash'
2
+
3
+ /**
4
+ * Decode a base64 thumbhash (as stored by the media pipeline, `derive.ts`) to a tiny PNG data URL — or
5
+ * null when absent/malformed. Pure + deterministic. Decoded CLIENT-SIDE only (see `useBlurUp`): the
6
+ * ~6 KB PNG data URL must not be inlined in SSR HTML — the ~30-byte thumbhash string ships in the
7
+ * payload and is inflated here after mount. Never throws — bad input degrades to null (no placeholder).
8
+ */
9
+ export function thumbhashToDataUrl(thumbhash: string | null | undefined): string | null {
10
+ if (!thumbhash) return null
11
+ try {
12
+ const bin = atob(thumbhash)
13
+ const bytes = new Uint8Array(bin.length)
14
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
15
+ return thumbHashToDataURL(bytes)
16
+ } catch {
17
+ return null
18
+ }
19
+ }
@@ -0,0 +1,20 @@
1
+ import type { VariantFit } from '../../../core/server/utils/kestrel-config'
2
+
3
+ export interface VariantShape {
4
+ width: number
5
+ /** A fixed-box height (crop). null/undefined ⇒ proportional (width-only). */
6
+ height?: number | null
7
+ fit?: VariantFit
8
+ }
9
+
10
+ /**
11
+ * Canonical stable name for a variant shape — the manifest/object-key stem. This ONE function is shared by
12
+ * the render lookup (`KestrelImg` / `useMediaVariant`), the prerender scan that registers a discovered spec,
13
+ * and (via the registry) `deriveImage`, so all three agree on the name. Names stay `[A-Za-z0-9_-]` so the
14
+ * `<name>.<format>` manifest key and the relocate parser split cleanly on the last dot.
15
+ */
16
+ export function variantName(shape: VariantShape): string {
17
+ if (shape.height == null) return `w${shape.width}`
18
+ const fit = shape.fit ?? 'cover'
19
+ return fit === 'cover' ? `c${shape.width}x${shape.height}` : `c${shape.width}x${shape.height}-${fit}`
20
+ }
@@ -0,0 +1,3 @@
1
+ // Marks `media` as a Nuxt layer (so its components/server code are auto-registered). The media config
2
+ // + the `kestrel` module that resolves it now live in the core layer (single non-auth config source).
3
+ export default defineNuxtConfig({})
@@ -0,0 +1,8 @@
1
+ import { findMediaUsages } from '../../../utils/usages'
2
+ import { requireMediaCollection } from '../../../utils/media-enabled'
3
+
4
+ export default defineEventHandler((event) => {
5
+ requireMediaCollection()
6
+ const id = requireId(event)
7
+ return { id, usages: findMediaUsages(useDb(), id) }
8
+ })
@@ -0,0 +1,18 @@
1
+ import { eq, getTableColumns } from 'drizzle-orm'
2
+ import { media } from '../../collections/media'
3
+ import { useStorageDriver } from '../../../../core/server/utils/storage'
4
+ import { deleteAffected } from '../../utils/media-ops'
5
+ import { requireMediaCollection } from '../../utils/media-enabled'
6
+
7
+ export default defineEventHandler(async (event) => {
8
+ requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
9
+ requireMediaCollection()
10
+ const id = requireId(event)
11
+ const cols = getTableColumns(media) as Record<string, never>
12
+ const db = useDb()
13
+ // deleteAffected silently no-ops on a missing row, so keep an explicit 404 guard.
14
+ const row = db.select({ id: cols.id }).from(media).where(eq(cols.id, id)).get() as { id: number } | undefined
15
+ if (!row) throw createError({ statusCode: 404, statusMessage: `media ${id} not found` })
16
+ await deleteAffected(db, useStorageDriver(), [{ type: 'file', id }], false)
17
+ return { deleted: true, id }
18
+ })
@@ -0,0 +1,10 @@
1
+ import media from '../../collections/media'
2
+ import { requireMediaCollection } from '../../utils/media-enabled'
3
+
4
+ export default defineEventHandler((event) => {
5
+ requireMediaCollection()
6
+ const query = getQuery(event)
7
+ const depth = Number(query.depth ?? 0)
8
+ const locale = query.locale as string | undefined
9
+ return getOne(useDb(), media, requireId(event), depth, locale)
10
+ })