@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,70 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { useBlurUp } from '../composables/useBlurUp'
4
+
5
+ interface ResolvedMedia {
6
+ alt: string | null
7
+ width: number | null
8
+ height: number | null
9
+ thumbhash: string | null
10
+ src: string
11
+ srcset: { url: string; width: number }[]
12
+ }
13
+ // `sizes` lets the consuming layout declare the rendered slot width (e.g.
14
+ // `(min-width: 768px) 50vw, 100vw`) so the browser picks an appropriately small
15
+ // `srcset` candidate instead of defaulting to 100vw and over-fetching the largest.
16
+ // `priority` marks the above-the-fold LCP image: load it eagerly with a high fetch priority instead of
17
+ // lazily, so the browser fetches it immediately rather than deferring the most important pixel. Opt-in
18
+ // per call site (the editor can't know which block is the LCP one); leave it off for everything else.
19
+ const props = defineProps<{ media: ResolvedMedia; sizes?: string; priority?: boolean }>()
20
+ // Computed, not a captured const: the live preview REUSES this instance (BlockRenderer keys blocks by id,
21
+ // consumer carousels key slides by index), so a media reorder/replace only patches the `media` prop — a
22
+ // one-shot const would keep the browser painting the OLD image via its stale `w`-descriptor candidate set.
23
+ const srcset = computed(() => props.media.srcset.map((s) => `${s.url} ${s.width}w`).join(', ') || undefined)
24
+
25
+ // Blur-up: a thumbhash placeholder shows until the raster paints, then a one-shot "sharpen-in" resolves
26
+ // the blur into the crisp image (see useBlurUp for the SSR/no-JS/cached-image degradation rules). The
27
+ // placeholder is painted as the <img>'s own background, so no wrapper element is introduced — the bare
28
+ // <img> keeps the public site's `img` box model (the reset's display:block / max-width:100%) intact.
29
+ const { imgEl, animate, placeholderStyle, onLoad } = useBlurUp(() => props.media.thumbhash)
30
+ </script>
31
+
32
+ <template>
33
+ <img
34
+ ref="imgEl"
35
+ :src="media.src"
36
+ :srcset="srcset"
37
+ :sizes="sizes"
38
+ :width="media.width ?? undefined"
39
+ :height="media.height ?? undefined"
40
+ :alt="media.alt ?? ''"
41
+ :class="{ 'media-image--in': animate }"
42
+ :style="placeholderStyle"
43
+ :loading="priority ? 'eager' : 'lazy'"
44
+ :fetchpriority="priority ? 'high' : undefined"
45
+ decoding="async"
46
+ @load="onLoad"
47
+ >
48
+ </template>
49
+
50
+ <style scoped>
51
+ /* Sharpen-in: when the real raster finishes loading it briefly starts soft and resolves to crisp, so the
52
+ swap from the thumbhash placeholder reads as a smooth focus pull rather than a hard snap. The class is
53
+ applied by JS only on a genuine async load, so SSR / no-JS / cached loads just show the image. */
54
+ @keyframes media-sharpen {
55
+ from {
56
+ filter: blur(8px);
57
+ }
58
+ to {
59
+ filter: blur(0);
60
+ }
61
+ }
62
+ .media-image--in {
63
+ animation: media-sharpen 0.5s ease-out;
64
+ }
65
+ @media (prefers-reduced-motion: reduce) {
66
+ .media-image--in {
67
+ animation: none;
68
+ }
69
+ }
70
+ </style>
@@ -0,0 +1,451 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed } from 'vue'
3
+ import type { LibraryFile, LibraryItem } from '../utils/library'
4
+ import type { DropResult } from '../composables/useMediaDnd'
5
+ import type { DeleteReport, OpItem, Conflict } from '../utils/ops'
6
+ import { toOpItem, effectiveTargets } from '../utils/ops'
7
+
8
+ // The library API response carries `updatedAt` (server/utils/library.ts) that `LibraryFile` doesn't yet
9
+ // declare — widen locally rather than read it untyped.
10
+ type LibraryFileRow = LibraryFile & { updatedAt?: string }
11
+
12
+ const props = defineProps<{ pick?: boolean; multiple?: boolean; accept?: 'image' | 'any'; initialFolder?: string; initialSelected?: number[] }>()
13
+ const emit = defineEmits<{ confirm: [number[]]; cancel: [] }>()
14
+
15
+ const lib = useMediaLibrary({ urlSync: !props.pick, accept: props.accept, initialFolder: props.pick ? props.initialFolder : undefined })
16
+ const { folder, view, search, sort, items, parentPath, isSelected, hasMore, loading, error } = lib
17
+
18
+ // Managed multi-pick: the checked file ids persist across folder navigation (unlike lib.selected, which
19
+ // is per-folder + wiped on nav). Seeded from the field's current value so it opens pre-checked.
20
+ const picked = ref<Set<number>>(new Set(props.pick && props.multiple ? props.initialSelected ?? [] : []))
21
+ const isSelectedItem = (i: LibraryItem) => (props.pick && props.multiple
22
+ ? i.type === 'file' && picked.value.has(i.file.id)
23
+ : isSelected(i))
24
+
25
+ const { t } = useT()
26
+ const toast = useToast()
27
+ const upload = useMediaUpload({
28
+ onSettled: () => lib.fetchLibrary(),
29
+ // Each failed item raises its own reason (server statusMessage, e.g. "Payload too large") — the
30
+ // running counter only ever showed how *many* failed, never *why*.
31
+ // `||` (not `??`): an empty-string reason (e.g. a blank HTTP/2 statusText that slipped through) must
32
+ // still degrade to the localized fallback rather than render "<file>: " with a dangling colon.
33
+ onError: (item) => toast.error(t('media.uploadError', { name: item.filename, reason: item.message || t('media.uploadFailedReason') })),
34
+ })
35
+ const { conflicts, active, counts } = upload
36
+ const newFolderOpen = ref(false)
37
+
38
+ // Single-file viewer (general info + alt editor for images), opened on double-click.
39
+ const { primary } = useContentLocales()
40
+ const viewerOpen = ref(false)
41
+ const viewerFile = ref<LibraryFile | null>(null)
42
+ const viewerBusy = ref(false)
43
+ const viewerError = ref('')
44
+ function onOpenItem(item: LibraryItem) {
45
+ // In the PICKER there is no viewer (it would stack a dialog in a dialog). The grid/table emit 'open' on
46
+ // Enter (the keyboard activate) — so in pick mode route Enter to the same SELECT action a click does,
47
+ // otherwise Enter would be a dead key for keyboard pickers. Outside the picker, Enter opens the viewer.
48
+ if (item.type !== 'file') return
49
+ if (props.pick) { onSelect(item, { toggle: false, range: false }); return }
50
+ viewerFile.value = item.file
51
+ viewerError.value = ''
52
+ viewerOpen.value = true
53
+ }
54
+ async function onSaveAlt(alt: string) {
55
+ const f = viewerFile.value as LibraryFileRow | null
56
+ if (!f) return
57
+ viewerBusy.value = true
58
+ viewerError.value = ''
59
+ try {
60
+ // Alt lives in the per-locale translations JSON; write the primary locale (the library resolves
61
+ // alt in that same locale, so this round-trips into the list). The precondition header lets the
62
+ // server 409 instead of silently losing a concurrent edit from another open viewer tab.
63
+ await $fetch(`/api/media/${f.id}`, {
64
+ method: 'PATCH',
65
+ body: { translations: { [primary]: { alt } } },
66
+ ...(f.updatedAt ? { headers: { 'x-kestrel-if-unmodified-since': String(new Date(f.updatedAt).getTime()) } } : {}),
67
+ })
68
+ } catch (e) {
69
+ viewerError.value = (e as { statusMessage?: string })?.statusMessage ?? t('mediaViewer.saveFailed')
70
+ return // keep the viewer open so the error is visible
71
+ } finally {
72
+ viewerBusy.value = false
73
+ }
74
+ // Saved → close, then refresh the list (a refresh hiccup isn't a save failure, so don't surface it).
75
+ viewerOpen.value = false
76
+ lib.fetchLibrary()
77
+ }
78
+
79
+ function onSelect(item: LibraryItem, mods: { toggle: boolean; range: boolean }) {
80
+ // Managed multi-pick: a plain click toggles the file's membership in the persistent `picked` set
81
+ // (folders aren't pickable — a modifier-click on one is ignored here).
82
+ if (props.pick && props.multiple) {
83
+ if (item.type !== 'file') return
84
+ const s = new Set(picked.value)
85
+ s.has(item.file.id) ? s.delete(item.file.id) : s.add(item.file.id)
86
+ picked.value = s
87
+ return
88
+ }
89
+ // single-pick is always exactly one — modifier clicks must not let it accumulate
90
+ if (props.pick && !props.multiple) { lib.select(item); return }
91
+ if (mods.range) lib.range(item)
92
+ else if (mods.toggle) lib.toggle(item)
93
+ else if (props.pick) lib.toggle(item)
94
+ else lib.select(item)
95
+ }
96
+
97
+ const selectedFileIds = computed(() => (props.pick && props.multiple
98
+ ? [...picked.value]
99
+ : items.value
100
+ .filter((i): i is Extract<LibraryItem, { type: 'file' }> => i.type === 'file' && isSelected(i))
101
+ .map((i) => i.file.id)))
102
+ function onConfirmPick() { emit('confirm', selectedFileIds.value) }
103
+
104
+ // A not-found folder (error set) must not be silently created by uploading / new-folder / paste / drop
105
+ // into it — that would contradict the "does not exist" banner. Gate every write path on `error`.
106
+ function onUpload(files: File[]) {
107
+ if (error.value) return
108
+ // start a fresh batch (reset cumulative counts) only once any prior batch has fully settled
109
+ if (!active.value && !conflicts.value.length) upload.reset()
110
+ upload.enqueue(files, folder.value)
111
+ }
112
+
113
+ async function onCreateFolder(name: string) {
114
+ if (error.value) return
115
+ await $fetch('/api/media/folders', {
116
+ method: 'POST',
117
+ body: { path: folder.value ? `${folder.value}/${name}` : name },
118
+ })
119
+ newFolderOpen.value = false
120
+ lib.fetchLibrary()
121
+ }
122
+
123
+ async function onDropResult({ uploads, folders }: DropResult) {
124
+ if (error.value) return
125
+ for (const path of folders) {
126
+ await $fetch('/api/media/folders', { method: 'POST', body: { path } })
127
+ }
128
+ if (uploads.length) {
129
+ if (!active.value && !conflicts.value.length) upload.reset()
130
+ upload.enqueueUploads(uploads)
131
+ } else if (folders.length) {
132
+ lib.fetchLibrary()
133
+ }
134
+ }
135
+
136
+ const draggedItems = ref<OpItem[]>([])
137
+ function onItemDragStart(item: LibraryItem, e: DragEvent) {
138
+ const set = effectiveTargets(item, lib.isSelected, items.value.filter((i) => lib.isSelected(i)))
139
+ draggedItems.value = set.map(toOpItem)
140
+ e.dataTransfer?.setData('application/x-kestrel-media', '1')
141
+ if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'
142
+ }
143
+ function onItemDragEnd() { draggedItems.value = [] }
144
+
145
+ const { dragActive, dropFolder, onDragEnter, onDragOver, onDragLeave, onDrop } = useMediaDnd({
146
+ currentFolder: () => folder.value,
147
+ onDrop: onDropResult,
148
+ draggedItems: () => draggedItems.value,
149
+ onMove,
150
+ })
151
+
152
+ const ops = useMediaOps(() => { lib.fetchLibrary(); lib.clear() })
153
+ const { busy: opsBusy, error: opError } = ops
154
+ const deleteOpen = ref(false)
155
+ const deleteReport = ref<DeleteReport | null>(null)
156
+ const pendingDelete = ref<OpItem[]>([])
157
+ const fileNames = computed(() => Object.fromEntries(
158
+ items.value.filter((i) => i.type === 'file').map((i) => [i.file.id, i.file.filename]),
159
+ ))
160
+
161
+ const renameOpen = ref(false)
162
+ const renameTarget = ref<LibraryItem | null>(null)
163
+ const renameName = computed(() => {
164
+ const t = renameTarget.value
165
+ if (!t) return ''
166
+ return t.type === 'file' ? t.file.filename : t.folder.path.split('/').pop() ?? ''
167
+ })
168
+
169
+ async function onConfirmRename(name: string) {
170
+ if (!renameTarget.value) return
171
+ try {
172
+ await ops.rename(toOpItem(renameTarget.value), name)
173
+ renameOpen.value = false
174
+ renameTarget.value = null
175
+ } catch {
176
+ // opError is set by ops.rename and shown inside the open rename dialog
177
+ }
178
+ }
179
+
180
+ const { clipboard, isEmpty: clipboardEmpty, count: clipboardCount, cut, copy, clear: clearClipboard } = useMediaClipboard()
181
+
182
+ // Mirrors the two v-if-toggled visual paragraphs below into one region that is ALWAYS in the DOM: a
183
+ // live region inserted already containing text is not announced, so a v-if'd aria-live silences the
184
+ // very first clipboard/upload message. This one starts empty and only its text content changes.
185
+ const srStatus = computed(() => {
186
+ const parts: string[] = []
187
+ if (!clipboardEmpty.value) {
188
+ parts.push(`${clipboardCount.value} ${clipboardCount.value === 1 ? t('media.clipboardItem') : t('media.clipboardItems')} ${clipboard.value?.mode === 'cut' ? t('media.clipboardCut') : t('media.clipboardCopied')}`)
189
+ }
190
+ if (active.value || counts.value.done || counts.value.error || counts.value.skipped) {
191
+ let msg = active.value ? `${t('media.uploading')}${counts.value.done} ${t('media.uploadUploaded')}` : `${counts.value.done} ${t('media.uploadUploaded')}`
192
+ if (counts.value.error) msg += `, ${counts.value.error} ${t('media.uploadFailed')}`
193
+ if (counts.value.skipped) msg += `, ${counts.value.skipped} ${t('media.uploadSkipped')}`
194
+ parts.push(msg)
195
+ }
196
+ return parts.join('. ')
197
+ })
198
+ const relocate = useMediaRelocate(() => { lib.fetchLibrary(); lib.clear() })
199
+ const { busy: relocateBusy, error: relocateError } = relocate
200
+
201
+ const conflictOpen = ref(false)
202
+ const pasteConflicts = ref<Conflict[]>([])
203
+ const pendingRelocate = ref<{ type: 'move' | 'copy'; items: OpItem[]; dest: string; onSuccess?: () => void } | null>(null)
204
+
205
+ async function doRelocate(type: 'move' | 'copy', opItems: OpItem[], dest: string, onSuccess?: () => void) {
206
+ try {
207
+ const report = await relocate.preview(type, opItems, dest)
208
+ if (report.conflicts.length) {
209
+ pendingRelocate.value = { type, items: opItems, dest, onSuccess }
210
+ pasteConflicts.value = report.conflicts
211
+ conflictOpen.value = true
212
+ } else {
213
+ await relocate.execute(type, opItems, dest, 'abort')
214
+ onSuccess?.()
215
+ }
216
+ } catch {
217
+ // relocateError surfaces in the library alert
218
+ }
219
+ }
220
+
221
+ function onPaste(dest: string) {
222
+ if (error.value) return
223
+ const cb = clipboard.value
224
+ if (!cb) return
225
+ const type = cb.mode === 'cut' ? 'move' : 'copy'
226
+ doRelocate(type, cb.items, dest, cb.mode === 'cut' ? () => clearClipboard() : undefined)
227
+ }
228
+
229
+ function onMove(opItems: OpItem[], dest: string) {
230
+ doRelocate('move', opItems, dest)
231
+ }
232
+
233
+ async function onResolveConflict(strategy: 'skip' | 'overwrite' | 'rename') {
234
+ const p = pendingRelocate.value
235
+ if (!p) return
236
+ try {
237
+ await relocate.execute(p.type, p.items, p.dest, strategy)
238
+ conflictOpen.value = false
239
+ pendingRelocate.value = null
240
+ pasteConflicts.value = []
241
+ p.onSuccess?.()
242
+ } catch {
243
+ // relocateError shown inside the open conflict dialog
244
+ }
245
+ }
246
+
247
+ // destructured as `onMenuSelect` to avoid clashing with the existing item-select handler `onSelect`
248
+ const { menuItems, onContextMenu, onSelect: onMenuSelect } = useMediaContextMenu({
249
+ items: () => items.value,
250
+ isSelected: lib.isSelected,
251
+ select: lib.select,
252
+ onDelete: async (opItems) => {
253
+ try {
254
+ // set the report + pending items together (after the await) so concurrent previews can't
255
+ // open the dialog with one op's summary while a later op's items are pending
256
+ const report = await ops.previewDelete(opItems)
257
+ pendingDelete.value = opItems
258
+ deleteReport.value = report
259
+ deleteOpen.value = true
260
+ } catch {
261
+ // opError is set by previewDelete and surfaced by the library alert below
262
+ }
263
+ },
264
+ onRename: (item) => { opError.value = null; renameTarget.value = item; renameOpen.value = true },
265
+ currentFolder: () => folder.value,
266
+ clipboardEmpty: () => clipboardEmpty.value,
267
+ onCut: (opItems) => cut(opItems),
268
+ onCopy: (opItems) => copy(opItems),
269
+ onPaste,
270
+ })
271
+
272
+ async function onConfirmDelete() {
273
+ try {
274
+ await ops.confirmDelete(pendingDelete.value)
275
+ deleteOpen.value = false
276
+ deleteReport.value = null
277
+ } catch {
278
+ // opError is set by confirmDelete and shown inside the still-open dialog
279
+ }
280
+ }
281
+
282
+ // Resolve the context-menu label keys here (the composable stays pure/node-testable, returning specs).
283
+ const localizedMenu = computed(() => menuItems.value.map((s) => ({
284
+ label: t(s.labelKey, s.count != null ? { n: s.count } : undefined),
285
+ value: s.value,
286
+ ...(s.danger ? { danger: true } : {}),
287
+ })))
288
+ </script>
289
+
290
+ <template>
291
+ <section class="media-library" @click.self="lib.clear()" @dragenter="onDragEnter" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
292
+ <MediaPathBar :folder="folder" @navigate="lib.navigate" />
293
+ <MediaToolbar
294
+ :view="view"
295
+ :search="search"
296
+ :disabled="!!error"
297
+ @update:view="lib.setView"
298
+ @update:search="lib.setSearch"
299
+ @upload="onUpload"
300
+ @new-folder="newFolderOpen = true"
301
+ />
302
+ <UiMenu :items="localizedMenu" @select="onMenuSelect">
303
+ <!-- @contextmenu.capture sets the menu target before Reka's own handler opens the menu, so menuItems is correct when the menu renders -->
304
+ <div class="media-library__items" @contextmenu.capture="onContextMenu">
305
+ <UiAlert v-if="error" variant="error">{{ error }}</UiAlert>
306
+ <p v-else-if="!loading && !items.length" class="media-library__empty">{{ t('media.folderEmpty') }}</p>
307
+ <MediaGrid v-else-if="view === 'grid'" :items="items" :is-selected="isSelectedItem" :drop-target-path="dropFolder" :parent-path="parentPath" :up-label="t('media.parentFolder')" @navigate="lib.navigate" @select="onSelect" @open="onOpenItem" @dragstart="onItemDragStart" @dragend="onItemDragEnd" />
308
+ <MediaTable v-else :items="items" :is-selected="isSelectedItem" :drop-target-path="dropFolder" :sort="sort" :parent-path="parentPath" :up-label="t('media.parentFolder')" @navigate="lib.navigate" @select="onSelect" @open="onOpenItem" @sort="lib.setSort" @dragstart="onItemDragStart" @dragend="onItemDragEnd" />
309
+ </div>
310
+ </UiMenu>
311
+ <div v-if="hasMore" class="media-library__more">
312
+ <UiButton :disabled="loading" @click="lib.loadMore">{{ t('media.loadMore') }}</UiButton>
313
+ </div>
314
+ <p v-if="!clipboardEmpty" class="media-library__clipboard">
315
+ {{ clipboardCount }} {{ clipboardCount === 1 ? t('media.clipboardItem') : t('media.clipboardItems') }} {{ clipboard?.mode === 'cut' ? t('media.clipboardCut') : t('media.clipboardCopied') }}
316
+ <UiButton size="sm" :disabled="!!error" @click="onPaste(folder)">{{ t('media.pasteHere') }}</UiButton>
317
+ <UiButton size="sm" variant="ghost" @click="clearClipboard()">{{ t('media.clear') }}</UiButton>
318
+ </p>
319
+ <p v-if="active || counts.done || counts.error || counts.skipped" class="media-library__status">
320
+ <span v-if="active">{{ t('media.uploading') }}</span>{{ counts.done }} {{ t('media.uploadUploaded') }}<span v-if="counts.error">, {{ counts.error }} {{ t('media.uploadFailed') }}</span><span v-if="counts.skipped">, {{ counts.skipped }} {{ t('media.uploadSkipped') }}</span>
321
+ </p>
322
+ <p class="media-library__sr-status" role="status" aria-live="polite">{{ srStatus }}</p>
323
+ <div v-if="pick" class="media-library__pickbar">
324
+ <span>{{ selectedFileIds.length }} {{ t('media.selected') }}</span>
325
+ <UiButton variant="primary" :disabled="!selectedFileIds.length" @click="onConfirmPick">{{ t('media.useSelected') }}</UiButton>
326
+ <UiButton variant="ghost" @click="emit('cancel')">{{ t('common.cancel') }}</UiButton>
327
+ </div>
328
+ <MediaUploadDialog
329
+ :conflicts="conflicts"
330
+ :open="conflicts.length > 0"
331
+ @resolve="upload.resolve"
332
+ @resolve-all="upload.resolveAll"
333
+ @update:open="(v) => { if (!v) upload.resolveAll('skip') }"
334
+ />
335
+ <MediaNewFolderDialog v-model:open="newFolderOpen" @create="onCreateFolder" />
336
+ <MediaViewer :open="viewerOpen" :file="viewerFile" :busy="viewerBusy" :error="viewerError" @update:open="(v) => { viewerOpen = v }" @save="onSaveAlt" />
337
+ <UiAlert v-if="opError && !deleteOpen && !renameOpen" variant="error">{{ opError }}</UiAlert>
338
+ <MediaDeleteDialog
339
+ :open="deleteOpen"
340
+ :report="deleteReport"
341
+ :names="fileNames"
342
+ :busy="opsBusy"
343
+ :error="opError"
344
+ @confirm="onConfirmDelete"
345
+ @update:open="(v) => { if (!v) { deleteOpen = false; deleteReport = null } }"
346
+ />
347
+ <MediaRenameDialog
348
+ :open="renameOpen"
349
+ :name="renameName"
350
+ :busy="opsBusy"
351
+ :error="opError"
352
+ @rename="onConfirmRename"
353
+ @update:open="(v) => { if (!v) { renameOpen = false; renameTarget = null } }"
354
+ />
355
+ <UiAlert v-if="relocateError && !conflictOpen" variant="error">{{ relocateError }}</UiAlert>
356
+ <MediaConflictDialog
357
+ :open="conflictOpen"
358
+ :conflicts="pasteConflicts"
359
+ :type="pendingRelocate?.type ?? 'move'"
360
+ :dest="pendingRelocate?.dest ?? ''"
361
+ :busy="relocateBusy"
362
+ :error="relocateError"
363
+ @resolve="onResolveConflict"
364
+ @update:open="(v) => { if (!v) { conflictOpen = false; pendingRelocate = null; pasteConflicts = []; relocateError = null } }"
365
+ />
366
+ <div v-if="dragActive && !dropFolder && !error" class="media-library__dropzone" aria-hidden="true">{{ t('media.dropToUpload') }}</div>
367
+ </section>
368
+ </template>
369
+
370
+ <style lang="scss" scoped>
371
+ .media-library {
372
+ position: relative;
373
+ display: flex;
374
+ flex-direction: column;
375
+ gap: var(--space-4);
376
+ // Page mode: fill the bounded height from .media-page so the grid scrolls internally. In the picker
377
+ // dialog these are inert (the parent is a plain block, so no bounded height reaches here) and the
378
+ // dialog scrolls as a whole — .media-library__items' overflow never engages there.
379
+ flex: 1 1 auto;
380
+ min-height: 0;
381
+ }
382
+
383
+ // The one scroll region: the file grid/table. Everything else (path bar, toolbar, load-more, clipboard,
384
+ // status, pick bar) is fixed chrome.
385
+ .media-library > :not(.media-library__items) {
386
+ flex: 0 0 auto;
387
+ }
388
+ .media-library__items {
389
+ flex: 1 1 auto;
390
+ min-height: 0;
391
+ overflow: auto;
392
+ }
393
+
394
+ .media-library__dropzone {
395
+ position: absolute;
396
+ inset: 0;
397
+ z-index: var(--z-overlay);
398
+ display: grid;
399
+ place-items: center;
400
+ background: var(--color-scrim);
401
+ color: var(--color-on-primary);
402
+ font-size: var(--text-lg);
403
+ border-radius: var(--radius-md);
404
+ pointer-events: none; /* must not intercept dragover/drop events from the section */
405
+ }
406
+
407
+ .media-library__more {
408
+ display: flex;
409
+ justify-content: center;
410
+ }
411
+
412
+ .media-library__empty {
413
+ color: var(--color-text-muted);
414
+ padding: var(--space-6) 0;
415
+ text-align: center;
416
+ }
417
+
418
+ .media-library__status {
419
+ color: var(--color-text-muted);
420
+ font-size: var(--text-sm);
421
+ }
422
+
423
+ // Visually-hidden permanent live region (mirrors .list__sr-status in CollectionList).
424
+ .media-library__sr-status {
425
+ position: absolute;
426
+ width: 1px;
427
+ height: 1px;
428
+ padding: 0;
429
+ margin: -1px;
430
+ overflow: hidden;
431
+ clip: rect(0, 0, 0, 0);
432
+ white-space: nowrap;
433
+ }
434
+
435
+ .media-library__clipboard {
436
+ display: flex;
437
+ align-items: center;
438
+ gap: var(--space-2);
439
+ font-size: var(--text-sm);
440
+ color: var(--color-text-muted);
441
+ }
442
+
443
+ .media-library__pickbar {
444
+ display: flex;
445
+ align-items: center;
446
+ gap: var(--space-3);
447
+ justify-content: flex-end;
448
+ border-top: 1px solid var(--color-border);
449
+ padding-top: var(--space-3);
450
+ }
451
+ </style>
@@ -0,0 +1,32 @@
1
+ <script setup lang="ts">
2
+ import { ref, watch } from 'vue'
3
+
4
+ const props = defineProps<{ open: boolean }>()
5
+ const emit = defineEmits<{ create: [string]; 'update:open': [boolean] }>()
6
+
7
+ const { t } = useT()
8
+ const name = ref('')
9
+
10
+ watch(() => props.open, (o) => { if (o) name.value = '' })
11
+
12
+ function onCreate() {
13
+ const v = name.value.trim()
14
+ if (!v) return
15
+ emit('create', v)
16
+ emit('update:open', false)
17
+ }
18
+ </script>
19
+
20
+ <template>
21
+ <UiDialog :open="open" :title="t('media.newFolder')" @update:open="(v) => emit('update:open', v)">
22
+ <UiField :label="t('media.folderName')">
23
+ <template #default="f">
24
+ <UiTextInput v-model="name" :placeholder="t('media.folderNamePlaceholder')" v-bind="f" @keydown.enter="onCreate" />
25
+ </template>
26
+ </UiField>
27
+ <template #footer>
28
+ <UiButton @click="emit('update:open', false)">{{ t('common.cancel') }}</UiButton>
29
+ <UiButton variant="primary" :disabled="!name.trim()" @click="onCreate">{{ t('common.create') }}</UiButton>
30
+ </template>
31
+ </UiDialog>
32
+ </template>