@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,261 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch, nextTick } from 'vue'
3
+ import type { FieldComponentProps } from '../../../../ui/app/utils/field-component'
4
+ import type { FieldOf } from '../../../../core/server/utils/defineCollection'
5
+ import { reorder } from '../../../../ui/app/utils/reorder'
6
+ import { commonFolder } from '../../utils/library'
7
+
8
+ interface Resolved { id: number; folder?: string; src: string; alt: string | null; mime?: string; thumbhash?: string | null; srcset?: { url: string; width: number }[] }
9
+
10
+ // Honour the one shared widget contract. Narrow the media options via a computed guard, exactly as
11
+ // Relation.vue does for its relation options.
12
+ const props = defineProps<FieldComponentProps>()
13
+ const model = defineModel<number | number[] | null>()
14
+
15
+ const { t } = useT()
16
+ const options = computed(() => (props.field.type === 'media' ? (props.field as FieldOf<'media'>).options : undefined))
17
+ const required = computed(() => !!props.field.required)
18
+ const multiple = computed(() => !!options.value?.multiple)
19
+ const accept = computed(() => options.value?.accept ?? 'any')
20
+ const pickerOpen = ref(false)
21
+
22
+ const ids = computed<number[]>(() => {
23
+ const v = model.value
24
+ if (multiple.value) return Array.isArray(v) ? v : []
25
+ return typeof v === 'number' ? [v] : []
26
+ })
27
+
28
+ const { ensure, resolve } = useMediaResolver(() => props.locale)
29
+ const resolved = computed<Resolved[]>(() => ids.value.flatMap((id) => resolve(id) ?? []))
30
+ watch(ids, (v) => ensure(v), { immediate: true })
31
+
32
+ // Seed the picker's initial folder from the common folder of the current items so it opens where the
33
+ // selection lives (root when they diverge / nothing is selected yet).
34
+ const initialFolder = computed(() => commonFolder(resolved.value.map((r) => r.folder ?? '')))
35
+
36
+ function onConfirm(picked: number[]) {
37
+ if (multiple.value) {
38
+ // REPLACE, not merge: the picker returns the full desired set (pre-checked + toggled), so
39
+ // unchecking an item in the picker removes it here. Dedupe defensively.
40
+ model.value = [...new Set(picked)]
41
+ } else {
42
+ model.value = picked[0] ?? null
43
+ }
44
+ }
45
+ function removeId(id: number) {
46
+ if (multiple.value) model.value = (Array.isArray(model.value) ? model.value : []).filter((x) => x !== id)
47
+ else model.value = null
48
+ }
49
+
50
+ // Drag-to-reorder (multiple mode), mirroring the Combobox chip idiom. `resolved` is a SUBSEQUENCE of the
51
+ // model ids (a dangling/deleted id drops out), so a resolved position must be mapped back to the id's
52
+ // index in the model before reordering — a resolved index is NOT the model index.
53
+ const itemsEl = ref<HTMLElement | null>(null)
54
+ const liveMessage = ref('')
55
+ // A DISTINCT accessible name per item, so the move/remove buttons don't all read "media" when the images
56
+ // carry no alt text: alt → filename → "media #<pos>". Position keeps it unique even for same-named files.
57
+ const nameOf = (r: Resolved, i: number) => r.alt?.trim() || filenameOf(r) || t('field.media.itemAt', { pos: i + 1 })
58
+ function modelIndexOf(id: number) { return (Array.isArray(model.value) ? model.value : []).indexOf(id) }
59
+ function announceMove(name: string, to: number) {
60
+ liveMessage.value = t('field.media.moved', { name, pos: to + 1, total: resolved.value.length })
61
+ }
62
+ // `resolved` is a SUBSEQUENCE of the model ids (a dangling/deleted id drops out), so commit maps the
63
+ // resolved drag indices back to the ids' model indices before reordering — a resolved index ≠ model index.
64
+ const { dragIndex, overIndex, onDragStart, onDragEnter, onDragLeave, onDrop, onDragEnd } = useDragReorder({
65
+ disabled: () => props.disabled || !multiple.value,
66
+ commit: (fromResolved, toResolved) => {
67
+ const from = modelIndexOf(resolved.value[fromResolved]!.id)
68
+ const to = modelIndexOf(resolved.value[toResolved]!.id)
69
+ if (from < 0 || to < 0) return
70
+ const name = nameOf(resolved.value[fromResolved]!, fromResolved)
71
+ model.value = reorder(Array.isArray(model.value) ? model.value : [], from, to)
72
+ nextTick(() => announceMove(name, toResolved))
73
+ },
74
+ })
75
+ function moveItem(i: number, dir: -1 | 1) {
76
+ const j = i + dir
77
+ if (props.disabled || !multiple.value || j < 0 || j >= resolved.value.length) return
78
+ const from = modelIndexOf(resolved.value[i]!.id)
79
+ const to = modelIndexOf(resolved.value[j]!.id)
80
+ if (from < 0 || to < 0) return
81
+ const name = nameOf(resolved.value[i]!, i)
82
+ model.value = reorder(Array.isArray(model.value) ? model.value : [], from, to)
83
+ // Keep keyboard focus on a move control at the item's new position. At a boundary the direction button
84
+ // is disabled, so fall back to the OPPOSITE move button (still enabled for any ≥2-item list) — never to
85
+ // the destructive remove button, which would turn a repeated move into an accidental delete.
86
+ nextTick(() => {
87
+ announceMove(name, j)
88
+ const item = itemsEl.value?.children[j] as HTMLElement | undefined
89
+ const btns = item?.querySelectorAll<HTMLButtonElement>('.field-media__move')
90
+ const dirBtn = dir < 0 ? btns?.[0] : btns?.[1]
91
+ const otherBtn = dir < 0 ? btns?.[1] : btns?.[0]
92
+ const target = dirBtn && !dirBtn.disabled ? dirBtn : otherBtn && !otherBtn.disabled ? otherBtn : item
93
+ target?.focus()
94
+ })
95
+ }
96
+ function srcsetOf(r: Resolved) { return r.srcset?.map((s) => `${s.url} ${s.width}w`).join(', ') }
97
+
98
+ // Only image mimes get a thumbnail; everything else (pdf/doc/video/…) shows a file badge + filename so
99
+ // it isn't a broken <img>. Filename/extension are derived from the public URL (the resolved shape
100
+ // carries no filename); the badge falls back to the mime subtype when the URL has no extension.
101
+ function isImage(r: Resolved) { return (r.mime ?? '').startsWith('image/') }
102
+ function filenameOf(r: Resolved) {
103
+ const path = r.src.split(/[?#]/)[0]
104
+ return decodeURIComponent(path.slice(path.lastIndexOf('/') + 1))
105
+ }
106
+ function fileExt(r: Resolved) {
107
+ const name = filenameOf(r)
108
+ const dot = name.lastIndexOf('.')
109
+ if (dot > 0) return name.slice(dot + 1).toUpperCase()
110
+ return (r.mime ?? '').split('/')[1]?.toUpperCase() ?? ''
111
+ }
112
+
113
+ defineExpose({ onConfirm, removeId, onDragStart, onDrop, moveItem })
114
+ </script>
115
+
116
+ <template>
117
+ <UiField :id="id" :label="name" :error="error" :required="required">
118
+ <template #default="f">
119
+ <div class="field-media">
120
+ <ul v-if="resolved.length" ref="itemsEl" class="field-media__items" @dragleave="onDragLeave">
121
+ <li
122
+ v-for="(r, i) in resolved"
123
+ :key="r.id"
124
+ class="field-media__item"
125
+ :class="{
126
+ 'field-media__item--draggable': multiple && !disabled,
127
+ 'field-media__item--over': overIndex === i && dragIndex !== i,
128
+ }"
129
+ :draggable="multiple && !disabled"
130
+ @dragstart="onDragStart(i, $event)"
131
+ @dragenter="onDragEnter(i)"
132
+ @dragover.prevent="onDragEnter(i)"
133
+ @drop.prevent="onDrop(i)"
134
+ @dragend="onDragEnd"
135
+ >
136
+ <MediaThumb v-if="isImage(r)" draggable="false" class="field-media__thumb" :src="r.src" :srcset="srcsetOf(r)" sizes="80px" :alt="r.alt ?? ''" :thumbhash="r.thumbhash" />
137
+ <a v-else draggable="false" class="field-media__file" :href="r.src" target="_blank" rel="noopener noreferrer" :title="filenameOf(r)">
138
+ <span class="field-media__badge" aria-hidden="true">{{ fileExt(r) }}</span>
139
+ <span class="field-media__filename">{{ filenameOf(r) }}</span>
140
+ </a>
141
+ <template v-if="multiple && !disabled">
142
+ <button
143
+ type="button"
144
+ draggable="false"
145
+ class="field-media__move field-media__move--prev"
146
+ :aria-label="t('field.media.move_earlier', { name: nameOf(r, i) })"
147
+ :disabled="i === 0"
148
+ @click="moveItem(i, -1)"
149
+ ><UiIcon name="chevron-left" size="0.875rem" /></button>
150
+ <button
151
+ type="button"
152
+ draggable="false"
153
+ class="field-media__move field-media__move--next"
154
+ :aria-label="t('field.media.move_later', { name: nameOf(r, i) })"
155
+ :disabled="i === resolved.length - 1"
156
+ @click="moveItem(i, 1)"
157
+ ><UiIcon name="chevron-right" size="0.875rem" /></button>
158
+ </template>
159
+ <button v-if="!disabled" type="button" draggable="false" class="field-media__remove" :aria-label="t('field.media.remove', { name: nameOf(r, i) })" @click="removeId(r.id)"><UiIcon name="x" size="1rem" /></button>
160
+ </li>
161
+ </ul>
162
+ <p v-else class="field-media__empty">{{ t('field.media.empty') }}</p>
163
+ <UiButton
164
+ v-if="!disabled"
165
+ :id="f.id"
166
+ type="button"
167
+ variant="secondary"
168
+ :aria-invalid="f['aria-invalid']"
169
+ :aria-describedby="f['aria-describedby']"
170
+ @click="pickerOpen = true"
171
+ >
172
+ {{ multiple ? t('field.media.add') : (resolved.length ? t('field.media.replace') : t('field.media.select')) }}
173
+ </UiButton>
174
+ <span class="field-media__live" aria-live="polite">{{ liveMessage }}</span>
175
+ </div>
176
+ <MediaPicker v-model:open="pickerOpen" :multiple="multiple" :accept="accept" :initial-folder="initialFolder" :initial-selected="ids" @confirm="onConfirm" />
177
+ </template>
178
+ </UiField>
179
+ </template>
180
+
181
+ <style lang="scss" scoped>
182
+ .field-media { display: flex; flex-direction: column; gap: var(--space-2); align-items: flex-start; }
183
+ .field-media__items { display: flex; flex-wrap: wrap; gap: var(--space-2); list-style: none; margin: 0; padding: 0; }
184
+ .field-media__item { position: relative; }
185
+ .field-media__item--draggable { cursor: grab; }
186
+ .field-media__item--draggable:active { cursor: grabbing; }
187
+ /* Drop indicator — outward outline (items are gap-spaced, never overflow-clipped). */
188
+ .field-media__item--over { outline: 2px solid var(--color-primary); outline-offset: 2px; border-radius: var(--radius-md); }
189
+ .field-media__thumb { width: 80px; height: 80px; object-fit: cover; border-radius: var(--radius-md); background: var(--color-bg); border: 1px solid var(--color-border); }
190
+ .field-media__file { display: flex; flex-direction: column; gap: var(--space-1); width: 80px; text-decoration: none; color: inherit; }
191
+ .field-media__badge { display: grid; place-items: center; width: 80px; height: 80px; border-radius: var(--radius-md); background: var(--color-bg); border: 1px solid var(--color-border); color: var(--color-text-muted); font-weight: var(--weight-bold); font-size: var(--text-sm); }
192
+ .field-media__filename { max-width: 80px; font-size: var(--text-xs); color: var(--color-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
193
+
194
+ /* Remove control — a dark translucent disc + white glyph reads on ANY image (a white surface disc was
195
+ invisible over pale photos); the light ring lifts it off, hover escalates to solid danger. */
196
+ .field-media__remove {
197
+ position: absolute;
198
+ top: 4px;
199
+ right: 4px;
200
+ width: 1.75rem;
201
+ height: 1.75rem;
202
+ display: grid;
203
+ place-items: center;
204
+ padding: 0;
205
+ border: 0;
206
+ border-radius: var(--radius-full);
207
+ background: rgba(9, 9, 11, 0.55);
208
+ color: #fff;
209
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.55), var(--shadow-sm);
210
+ cursor: pointer;
211
+ line-height: 1;
212
+ transition: background var(--motion-fast) var(--ease-standard),
213
+ transform var(--motion-fast) var(--ease-standard),
214
+ box-shadow var(--motion-fast) var(--ease-standard);
215
+ }
216
+ .field-media__remove:hover {
217
+ background: var(--color-danger-solid);
218
+ color: var(--color-on-danger);
219
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.7), var(--shadow-md);
220
+ transform: scale(1.08);
221
+ }
222
+ .field-media__remove:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
223
+
224
+ /* Keyboard reorder buttons — same disc idiom, revealed on hover/focus so idle thumbs stay clean.
225
+ Kept focusable when idle (opacity, not display:none) so Tab reveals them via :focus-within. */
226
+ .field-media__move {
227
+ position: absolute;
228
+ bottom: 4px;
229
+ width: 1.5rem;
230
+ height: 1.5rem;
231
+ display: grid;
232
+ place-items: center;
233
+ padding: 0;
234
+ border: 0;
235
+ border-radius: var(--radius-full);
236
+ background: rgba(9, 9, 11, 0.55);
237
+ color: #fff;
238
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.55);
239
+ cursor: pointer;
240
+ line-height: 1;
241
+ opacity: 0;
242
+ transition: opacity var(--motion-fast) var(--ease-standard);
243
+ }
244
+ .field-media__move--prev { left: 4px; }
245
+ .field-media__move--next { right: 4px; }
246
+ .field-media__move:disabled { cursor: default; } /* stays hidden at a boundary (reveal excludes :disabled) */
247
+ .field-media__item:hover .field-media__move:not(:disabled),
248
+ .field-media__item:focus-within .field-media__move:not(:disabled) { opacity: 1; }
249
+ .field-media__move:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
250
+ @media (hover: none) {
251
+ .field-media__move:not(:disabled) { opacity: 1; }
252
+ }
253
+ @media (prefers-reduced-motion: reduce) {
254
+ .field-media__remove,
255
+ .field-media__move { transition: none; }
256
+ .field-media__remove:hover { transform: none; }
257
+ }
258
+
259
+ .field-media__empty { color: var(--color-text-muted); font-size: var(--text-sm); margin: 0; }
260
+ .field-media__live { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
261
+ </style>
@@ -0,0 +1,55 @@
1
+ import { computed, ref, onMounted, watch, toValue, type MaybeRefOrGetter } from 'vue'
2
+ import { thumbhashToDataUrl } from '../utils/thumbhash'
3
+
4
+ /**
5
+ * Blur-up image loading state for an `<img>` painted over a thumbhash placeholder. Returns the bits a
6
+ * renderer binds: the element ref, the placeholder background style (shown until the raster paints), and
7
+ * an `onLoad` handler that drives a one-shot "sharpen-in".
8
+ *
9
+ * Degradation is deliberate: `animate`/`loaded`/`placeholder` are false/null on the server and the
10
+ * initial client render (so hydration matches and no-JS just shows the image — width/height reserve the
11
+ * box, so no layout shift). `animate` is set ONLY on a genuine async load, not for an image that was
12
+ * already `complete` at mount (a cached revisit), so a cached image doesn't flash a blur every time.
13
+ *
14
+ * The thumbhash placeholder is decoded CLIENT-SIDE only. `thumbHashToDataURL` inflates the ~30-byte
15
+ * thumbhash string (already shipped in the payload) into a ~6 KB PNG data URL; inlining that in SSR
16
+ * bloats the HTML by hundreds of KB on an image-dense page. Decoding after mount keeps the blur-up for
17
+ * JS clients while shipping a lean document (measured: −305 KB HTML on a 69-image page).
18
+ */
19
+ export function useBlurUp(thumbhash: MaybeRefOrGetter<string | null | undefined>) {
20
+ const imgEl = ref<HTMLImageElement | null>(null)
21
+ const loaded = ref(false)
22
+ const animate = ref(false)
23
+ const placeholder = ref<string | null>(null) // null on SSR + first client render; decoded on mount
24
+
25
+ const placeholderStyle = computed(() => {
26
+ if (!placeholder.value || loaded.value) return undefined
27
+ return {
28
+ backgroundImage: `url(${placeholder.value})`,
29
+ backgroundSize: 'cover',
30
+ backgroundPosition: 'center',
31
+ backgroundRepeat: 'no-repeat',
32
+ }
33
+ })
34
+
35
+ function onLoad() {
36
+ // A real (async) decode — the only case that should animate. A cached image is caught by the
37
+ // `complete` check below and flips `loaded` without arming `animate`.
38
+ if (!loaded.value) animate.value = true
39
+ loaded.value = true
40
+ }
41
+ onMounted(() => {
42
+ // A cached image is already painted → skip the placeholder entirely (no blur flash). Otherwise
43
+ // decode the thumbhash now (client-only) so the blur shows until the raster arrives.
44
+ if (imgEl.value?.complete) loaded.value = true
45
+ else placeholder.value = thumbhashToDataUrl(toValue(thumbhash))
46
+ })
47
+
48
+ // A reactive media swap (the public BlockRenderer is reused in the admin live preview and keys blocks
49
+ // by id, so editing an image reuses this instance instead of remounting) must restart the blur-up for
50
+ // the new image — re-decode the new placeholder. Not immediate, so the server/first-client render is
51
+ // untouched (no hydration impact).
52
+ watch(() => toValue(thumbhash), (h) => { loaded.value = false; animate.value = false; placeholder.value = thumbhashToDataUrl(h) })
53
+
54
+ return { imgEl, loaded, animate, placeholderStyle, onLoad }
55
+ }
@@ -0,0 +1,14 @@
1
+ import { ref, computed } from 'vue'
2
+ import type { OpItem } from '../utils/ops'
3
+
4
+ // Module-level singleton so the clipboard survives folder navigation + remounts.
5
+ const clipboard = ref<{ mode: 'cut' | 'copy'; items: OpItem[] } | null>(null)
6
+
7
+ export function useMediaClipboard() {
8
+ const isEmpty = computed(() => !clipboard.value?.items.length)
9
+ const count = computed(() => clipboard.value?.items.length ?? 0)
10
+ function cut(items: OpItem[]) { clipboard.value = { mode: 'cut', items } }
11
+ function copy(items: OpItem[]) { clipboard.value = { mode: 'copy', items } }
12
+ function clear() { clipboard.value = null }
13
+ return { clipboard, isEmpty, count, cut, copy, clear }
14
+ }
@@ -0,0 +1,56 @@
1
+ import { ref, computed } from 'vue'
2
+ import type { LibraryItem } from '../utils/library'
3
+ import { resolveTargetItem, effectiveTargets, toOpItem, buildMenuItems, type MenuItemSpec, type OpItem } from '../utils/ops'
4
+
5
+ export function useMediaContextMenu(opts: {
6
+ items: () => LibraryItem[]
7
+ isSelected: (i: LibraryItem) => boolean
8
+ select: (i: LibraryItem) => void
9
+ currentFolder: () => string
10
+ clipboardEmpty: () => boolean
11
+ onDelete: (items: OpItem[]) => void
12
+ onRename: (item: LibraryItem) => void
13
+ onCut: (items: OpItem[]) => void
14
+ onCopy: (items: OpItem[]) => void
15
+ onPaste: (dest: string) => void
16
+ }) {
17
+ const targets = ref<LibraryItem[]>([])
18
+ const targetType = ref<'file' | 'folder' | null>(null)
19
+ const pasteDest = ref('')
20
+
21
+ // Returns label *specs* (i18n keys); the component resolves them via useT so this composable stays
22
+ // pure and node-testable (no Nuxt/i18n context required).
23
+ const menuItems = computed<MenuItemSpec[]>(() =>
24
+ buildMenuItems({ targetCount: targets.value.length, targetType: targetType.value, clipboardEmpty: opts.clipboardEmpty() }),
25
+ )
26
+
27
+ function onContextMenu(e: MouseEvent) {
28
+ const all = opts.items()
29
+ const target = resolveTargetItem(e.target as Element, all)
30
+ if (!target) {
31
+ targets.value = []
32
+ targetType.value = null
33
+ // blank space: only open a menu (Paste) when the clipboard has something; else suppress
34
+ // both the native menu and an empty Reka menu (capture-phase, synchronous — a reactive
35
+ // :disabled can't, its prop updates after Reka's handler runs).
36
+ if (opts.clipboardEmpty()) { e.preventDefault?.(); e.stopPropagation?.(); return }
37
+ pasteDest.value = opts.currentFolder()
38
+ return
39
+ }
40
+ targets.value = effectiveTargets(target, opts.isSelected, all.filter((i) => opts.isSelected(i)))
41
+ targetType.value = target.type
42
+ pasteDest.value = target.type === 'folder' ? target.folder.path : ''
43
+ if (!opts.isSelected(target)) opts.select(target)
44
+ }
45
+
46
+ function onSelect(value: string) {
47
+ const opItems = targets.value.map(toOpItem)
48
+ if (value === 'delete') opts.onDelete(opItems)
49
+ else if (value === 'rename' && targets.value.length === 1) opts.onRename(targets.value[0])
50
+ else if (value === 'cut') opts.onCut(opItems)
51
+ else if (value === 'copy') opts.onCopy(opItems)
52
+ else if (value === 'paste') opts.onPaste(pasteDest.value)
53
+ }
54
+
55
+ return { targets, menuItems, onContextMenu, onSelect }
56
+ }
@@ -0,0 +1,56 @@
1
+ import { ref } from 'vue'
2
+ import { extractUploads, selectEmptyFolders, type PendingUpload } from '../utils/dnd'
3
+ import type { OpItem } from '../utils/ops'
4
+
5
+ const MEDIA_DRAG = 'application/x-kestrel-media'
6
+ export interface DropResult { uploads: PendingUpload[]; folders: string[] }
7
+
8
+ function hoveredFolder(e: DragEvent): string | null {
9
+ const el = (e.target as HTMLElement | null)?.closest?.('[data-drop-folder]') as HTMLElement | null
10
+ return el ? el.getAttribute('data-drop-folder') : null
11
+ }
12
+ function dragTypes(e: DragEvent): string[] { return e.dataTransfer ? Array.from(e.dataTransfer.types) : [] }
13
+ function isInternal(e: DragEvent): boolean { return dragTypes(e).includes(MEDIA_DRAG) }
14
+ function isFiles(e: DragEvent): boolean { return dragTypes(e).includes('Files') }
15
+
16
+ export function useMediaDnd(opts: {
17
+ currentFolder: () => string
18
+ onDrop: (r: DropResult) => void
19
+ draggedItems: () => OpItem[]
20
+ onMove: (items: OpItem[], dest: string) => void
21
+ }) {
22
+ const dragActive = ref(false)
23
+ const dropFolder = ref<string | null>(null)
24
+ let depth = 0
25
+
26
+ // Only a genuine OS file-drag raises the upload overlay. An internal item-drag may also carry 'Files'
27
+ // in dataTransfer.types on some platforms, so the internal marker takes precedence.
28
+ function onDragEnter(e: DragEvent) { e.preventDefault(); depth++; if (isFiles(e) && !isInternal(e)) dragActive.value = true }
29
+ function onDragOver(e: DragEvent) {
30
+ e.preventDefault()
31
+ if (e.dataTransfer) e.dataTransfer.dropEffect = isInternal(e) ? 'move' : 'copy'
32
+ dropFolder.value = hoveredFolder(e)
33
+ }
34
+ function onDragLeave(_e: DragEvent) {
35
+ depth = Math.max(0, depth - 1)
36
+ if (depth === 0) { dragActive.value = false; dropFolder.value = null }
37
+ }
38
+ async function onDrop(e: DragEvent) {
39
+ e.preventDefault()
40
+ depth = 0
41
+ dragActive.value = false
42
+ const dest = hoveredFolder(e)
43
+ dropFolder.value = null
44
+ if (!e.dataTransfer) return
45
+ if (isInternal(e)) {
46
+ const its = opts.draggedItems()
47
+ if (dest !== null && its.length && !its.some((i) => i.type === 'folder' && i.path === dest)) opts.onMove(its, dest)
48
+ return
49
+ }
50
+ const base = dest ?? opts.currentFolder()
51
+ const { uploads, dirs } = await extractUploads(e.dataTransfer, base)
52
+ opts.onDrop({ uploads, folders: selectEmptyFolders(uploads, dirs) })
53
+ }
54
+
55
+ return { dragActive, dropFolder, onDragEnter, onDragOver, onDragLeave, onDrop }
56
+ }
@@ -0,0 +1,151 @@
1
+ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
2
+ import { useRoute, useRouter } from '#imports'
3
+ import { itemKey, computeRange, parentFolder, type LibraryFile, type LibraryFolder, type LibraryItem } from '../utils/library'
4
+
5
+ interface LibraryResponse { folder: string; exists?: boolean; folders: LibraryFolder[]; files: LibraryFile[]; total: number; page: number; perPage: number }
6
+ const PER_PAGE = 60
7
+ const VIEW_KEY = 'kestrel-media-view'
8
+
9
+ export function useMediaLibrary(opts: { urlSync?: boolean; accept?: 'image' | 'any'; initialFolder?: string } = {}) {
10
+ const urlSync = opts.urlSync ?? true
11
+ const accept = opts.accept ?? 'any'
12
+
13
+ const route = useRoute()
14
+ const router = useRouter()
15
+ const { t } = useT()
16
+
17
+ const folder = ref<string>(urlSync && typeof route.query.folder === 'string' ? route.query.folder : (opts.initialFolder ?? ''))
18
+ const folders = ref<LibraryFolder[]>([])
19
+ const files = ref<LibraryFile[]>([])
20
+ const total = ref(0)
21
+ const page = ref(1)
22
+ const search = ref('')
23
+ const sort = ref<string>('name') // `field` (asc) or `-field` (desc); matches the table headers
24
+ const view = ref<'grid' | 'table'>('grid')
25
+ const loading = ref(false)
26
+ const error = ref<string | null>(null)
27
+ const selected = ref<Set<string>>(new Set())
28
+ let anchorKey: string | null = null
29
+ let token = 0
30
+ let searchTimer: ReturnType<typeof setTimeout> | undefined
31
+
32
+ if (import.meta.client) {
33
+ const v = localStorage.getItem(VIEW_KEY)
34
+ if (v === 'grid' || v === 'table') view.value = v
35
+ }
36
+
37
+ const items = computed<LibraryItem[]>(() => [
38
+ ...folders.value.map((f) => ({ type: 'folder' as const, folder: f })),
39
+ ...files.value.map((f) => ({ type: 'file' as const, file: f })),
40
+ ])
41
+ // Parent-folder ("..") target for up-navigation: null at the root (no item rendered there). Kept out
42
+ // of `items`/selection so it stays pure navigation chrome, never selectable or draggable.
43
+ const parentPath = computed(() => parentFolder(folder.value))
44
+ const orderedKeys = computed(() => items.value.map(itemKey))
45
+ const hasMore = computed(() => files.value.length < total.value)
46
+ const count = computed(() => selected.value.size)
47
+
48
+ async function fetchLibrary(append = false) {
49
+ // A non-append fetch is a full refresh (mount / nav / sort / search / a mutation's re-list): reset to
50
+ // page 1 so it never re-requests a deep page and assigns ONLY that page's slice — which after a
51
+ // "Load more" would silently drop every earlier page from the listing. loadMore() bumps page then appends.
52
+ if (!append) page.value = 1
53
+ const my = ++token
54
+ loading.value = true
55
+ error.value = null
56
+ try {
57
+ const res = await $fetch<LibraryResponse>('/api/media/library', {
58
+ query: { folder: folder.value, search: search.value || undefined, sort: sort.value, page: page.value, perPage: PER_PAGE, type: accept === 'image' ? 'image' : undefined },
59
+ })
60
+ if (my !== token) return
61
+ // A non-root path the server doesn't know is a typo/dead link, not an empty folder: surface an
62
+ // error rather than a blank listing that looks like the folder exists.
63
+ if (folder.value !== '' && res.exists === false) {
64
+ folders.value = []
65
+ files.value = []
66
+ total.value = 0
67
+ error.value = t('media.folderNotFound')
68
+ return
69
+ }
70
+ folders.value = res.folders
71
+ files.value = append ? [...files.value, ...res.files] : res.files
72
+ total.value = res.total
73
+ } catch (e) {
74
+ if (my !== token) return
75
+ error.value = (e as { statusMessage?: string })?.statusMessage ?? t('media.error.load')
76
+ } finally {
77
+ if (my === token) loading.value = false
78
+ }
79
+ }
80
+
81
+ function loadMore() {
82
+ if (!hasMore.value || loading.value) return
83
+ page.value += 1
84
+ return fetchLibrary(true)
85
+ }
86
+
87
+ // Returns the in-flight work so callers can await navigation: the fetch directly (urlSync:false), or
88
+ // the router push that the route watcher turns into a fetch (urlSync).
89
+ function navigate(path: string) {
90
+ if (path === folder.value) return
91
+ if (urlSync) return router.push({ query: path ? { folder: path } : {} })
92
+ clearTimeout(searchTimer)
93
+ search.value = ''
94
+ folder.value = path
95
+ page.value = 1
96
+ clear()
97
+ return fetchLibrary()
98
+ }
99
+
100
+ function setSearch(s: string) {
101
+ search.value = s
102
+ clearTimeout(searchTimer)
103
+ searchTimer = setTimeout(() => { page.value = 1; fetchLibrary() }, 250)
104
+ }
105
+
106
+ function setView(v: 'grid' | 'table') {
107
+ view.value = v
108
+ if (import.meta.client) localStorage.setItem(VIEW_KEY, v)
109
+ }
110
+
111
+ // Clicking a column re-sorts: same field flips direction, a new field starts ascending.
112
+ function setSort(field: string) {
113
+ sort.value = sort.value === field ? `-${field}` : field
114
+ page.value = 1
115
+ return fetchLibrary()
116
+ }
117
+
118
+ const isSelected = (item: LibraryItem) => selected.value.has(itemKey(item))
119
+ function select(item: LibraryItem) { const k = itemKey(item); selected.value = new Set([k]); anchorKey = k }
120
+ function toggle(item: LibraryItem) {
121
+ const k = itemKey(item); const s = new Set(selected.value)
122
+ s.has(k) ? s.delete(k) : s.add(k)
123
+ selected.value = s; anchorKey = k
124
+ }
125
+ function range(item: LibraryItem) { selected.value = computeRange(orderedKeys.value, anchorKey ?? itemKey(item), itemKey(item), selected.value) }
126
+ function clear() { selected.value = new Set(); anchorKey = null }
127
+ function selectAll() { selected.value = new Set(orderedKeys.value) }
128
+
129
+ if (urlSync) {
130
+ watch(() => route.query.folder, (q) => {
131
+ const next = typeof q === 'string' ? q : ''
132
+ if (next === folder.value) return
133
+ clearTimeout(searchTimer)
134
+ search.value = ''
135
+ folder.value = next
136
+ page.value = 1
137
+ clear()
138
+ fetchLibrary()
139
+ })
140
+ }
141
+
142
+ onMounted(() => fetchLibrary())
143
+ onUnmounted(() => clearTimeout(searchTimer))
144
+
145
+ return {
146
+ folder, folders, files, total, page, search, sort, view, loading, error,
147
+ items, parentPath, orderedKeys, hasMore, count, selected,
148
+ fetchLibrary, loadMore, navigate, setSearch, setView, setSort,
149
+ isSelected, select, toggle, range, clear, selectAll,
150
+ }
151
+ }
@@ -0,0 +1,50 @@
1
+ import { ref } from 'vue'
2
+ import type { OpItem, DeleteReport } from '../utils/ops'
3
+
4
+ export function useMediaOps(onChanged?: () => void) {
5
+ const busy = ref(false)
6
+ const error = ref<string | null>(null)
7
+
8
+ async function previewDelete(items: OpItem[]): Promise<DeleteReport> {
9
+ error.value = null
10
+ try {
11
+ return await $fetch<DeleteReport>('/api/media/delete', { method: 'POST', body: { items, dryRun: true } })
12
+ } catch (e) {
13
+ error.value = (e as { statusMessage?: string })?.statusMessage ?? 'Could not prepare delete'
14
+ throw e
15
+ }
16
+ }
17
+
18
+ async function confirmDelete(items: OpItem[]): Promise<void> {
19
+ busy.value = true
20
+ error.value = null
21
+ try {
22
+ await $fetch('/api/media/delete', { method: 'POST', body: { items } })
23
+ onChanged?.()
24
+ } catch (e) {
25
+ error.value = (e as { statusMessage?: string })?.statusMessage ?? 'Delete failed'
26
+ throw e
27
+ } finally {
28
+ busy.value = false
29
+ }
30
+ }
31
+
32
+ async function rename(item: OpItem, name: string): Promise<void> {
33
+ busy.value = true
34
+ error.value = null
35
+ try {
36
+ await $fetch('/api/media/rename', { method: 'POST', body: { items: [item], name, onConflict: 'abort' } })
37
+ onChanged?.()
38
+ } catch (e) {
39
+ const err = e as { statusCode?: number; statusMessage?: string }
40
+ error.value = err.statusCode === 409
41
+ ? 'A file or folder with this name already exists'
42
+ : err.statusMessage ?? 'Rename failed'
43
+ throw e
44
+ } finally {
45
+ busy.value = false
46
+ }
47
+ }
48
+
49
+ return { busy, error, previewDelete, confirmDelete, rename }
50
+ }