@michaelthielemann/kestrel 4.1.3 → 5.0.2

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 (342) hide show
  1. package/README.md +26 -201
  2. package/dist/boot.d.ts +45 -0
  3. package/dist/boot.js +284 -0
  4. package/dist/cast.d.ts +3 -0
  5. package/dist/cast.js +6 -0
  6. package/dist/catalogue.d.ts +11 -0
  7. package/dist/catalogue.js +1 -0
  8. package/dist/cli.d.ts +2 -0
  9. package/dist/cli.js +55 -0
  10. package/dist/context.d.ts +62 -0
  11. package/dist/context.js +61 -0
  12. package/dist/dataflow.d.ts +3 -0
  13. package/dist/dataflow.js +65 -0
  14. package/dist/defineConfig.d.ts +162 -0
  15. package/dist/defineConfig.js +42 -0
  16. package/dist/defineContract.d.ts +12 -0
  17. package/dist/defineContract.js +16 -0
  18. package/dist/defineModule.d.ts +75 -0
  19. package/dist/defineModule.js +9 -0
  20. package/dist/definePipeline.d.ts +7 -0
  21. package/dist/definePipeline.js +11 -0
  22. package/dist/errors.d.ts +26 -0
  23. package/dist/errors.js +48 -0
  24. package/dist/index.d.ts +19 -0
  25. package/dist/index.js +14 -0
  26. package/dist/load.d.ts +7 -0
  27. package/dist/load.js +79 -0
  28. package/dist/logger.d.ts +18 -0
  29. package/dist/logger.js +36 -0
  30. package/dist/registry.d.ts +14 -0
  31. package/dist/registry.js +87 -0
  32. package/dist/result.d.ts +19 -0
  33. package/dist/result.js +19 -0
  34. package/dist/runner.d.ts +29 -0
  35. package/dist/runner.js +133 -0
  36. package/dist/schema.d.ts +7 -0
  37. package/dist/schema.js +142 -0
  38. package/dist/sort.d.ts +2 -0
  39. package/dist/sort.js +50 -0
  40. package/dist/testing/runPipeline.d.ts +9 -0
  41. package/dist/testing/runPipeline.js +22 -0
  42. package/dist/triggers/allowlist.d.ts +9 -0
  43. package/dist/triggers/allowlist.js +49 -0
  44. package/dist/triggers/cron.d.ts +10 -0
  45. package/dist/triggers/cron.js +44 -0
  46. package/dist/triggers/cronMatch.d.ts +10 -0
  47. package/dist/triggers/cronMatch.js +68 -0
  48. package/dist/triggers/http.d.ts +59 -0
  49. package/dist/triggers/http.js +300 -0
  50. package/package.json +25 -151
  51. package/NOTICE +0 -12
  52. package/kestrel.config.ts +0 -28
  53. package/layers/access/nuxt.config.ts +0 -5
  54. package/layers/access/server/middleware/00.ip-allowlist.ts +0 -52
  55. package/layers/access/server/middleware/access-guard.ts +0 -32
  56. package/layers/access/server/types.d.ts +0 -7
  57. package/layers/admin/app/components/AdminAccount.vue +0 -212
  58. package/layers/admin/app/components/AdminNav.vue +0 -44
  59. package/layers/admin/app/components/BlockFields.vue +0 -67
  60. package/layers/admin/app/components/BlockPreview.vue +0 -413
  61. package/layers/admin/app/components/BlockTree.vue +0 -433
  62. package/layers/admin/app/components/BlocksBody.vue +0 -163
  63. package/layers/admin/app/components/CollectionDeleteDialog.vue +0 -38
  64. package/layers/admin/app/components/CollectionEditor.vue +0 -324
  65. package/layers/admin/app/components/CollectionList.vue +0 -429
  66. package/layers/admin/app/components/CollectionListBulkBar.vue +0 -62
  67. package/layers/admin/app/components/CollectionListColumnsPanel.vue +0 -37
  68. package/layers/admin/app/components/CollectionListFilterPanel.vue +0 -145
  69. package/layers/admin/app/components/CollectionListPager.vue +0 -70
  70. package/layers/admin/app/components/CollectionListTable.vue +0 -347
  71. package/layers/admin/app/components/EditorStatus.vue +0 -211
  72. package/layers/admin/app/components/EditorUnsupported.vue +0 -28
  73. package/layers/admin/app/components/FieldsBody.vue +0 -23
  74. package/layers/admin/app/components/LocaleBar.vue +0 -107
  75. package/layers/admin/app/components/PageFields.vue +0 -129
  76. package/layers/admin/app/components/PageFieldsPane.vue +0 -39
  77. package/layers/admin/app/components/SeoFields.vue +0 -210
  78. package/layers/admin/app/components/SingletonEditor.vue +0 -93
  79. package/layers/admin/app/composables/useAuth.ts +0 -68
  80. package/layers/admin/app/composables/useBlockTree.ts +0 -95
  81. package/layers/admin/app/composables/useBlocks.ts +0 -7
  82. package/layers/admin/app/composables/useCachedResource.ts +0 -35
  83. package/layers/admin/app/composables/useCollectionOps.ts +0 -102
  84. package/layers/admin/app/composables/useCollections.ts +0 -6
  85. package/layers/admin/app/composables/useContentLocales.ts +0 -23
  86. package/layers/admin/app/composables/useEditForm.ts +0 -402
  87. package/layers/admin/app/composables/useLinkResolver.ts +0 -40
  88. package/layers/admin/app/composables/useListBatchActions.ts +0 -78
  89. package/layers/admin/app/composables/useListColumns.ts +0 -53
  90. package/layers/admin/app/composables/useListFilterDraft.ts +0 -129
  91. package/layers/admin/app/composables/useListRows.ts +0 -75
  92. package/layers/admin/app/composables/useListSelection.ts +0 -28
  93. package/layers/admin/app/composables/useListUrlState.ts +0 -53
  94. package/layers/admin/app/composables/usePendingPublishPoll.ts +0 -15
  95. package/layers/admin/app/composables/usePreviewViewport.ts +0 -30
  96. package/layers/admin/app/composables/usePublishStatus.ts +0 -107
  97. package/layers/admin/app/composables/useRailCollapsed.ts +0 -14
  98. package/layers/admin/app/composables/useTheme.ts +0 -36
  99. package/layers/admin/app/composables/useToolbarPanel.ts +0 -38
  100. package/layers/admin/app/composables/useUnsavedGuard.ts +0 -22
  101. package/layers/admin/app/layouts/admin.vue +0 -266
  102. package/layers/admin/app/middleware/admin-auth.ts +0 -4
  103. package/layers/admin/app/pages/admin/[collection]/[id].vue +0 -203
  104. package/layers/admin/app/pages/admin/[collection]/index.vue +0 -62
  105. package/layers/admin/app/pages/admin/index.vue +0 -118
  106. package/layers/admin/app/pages/admin/login.vue +0 -68
  107. package/layers/admin/app/pages/admin/media.vue +0 -28
  108. package/layers/admin/app/pages/admin/references.vue +0 -146
  109. package/layers/admin/app/plugins/admin-style-guard.client.ts +0 -76
  110. package/layers/admin/app/plugins/reauth.client.ts +0 -20
  111. package/layers/admin/app/utils/admin-style-guard.ts +0 -20
  112. package/layers/admin/app/utils/block-tree.ts +0 -214
  113. package/layers/admin/app/utils/collection-ops.ts +0 -69
  114. package/layers/admin/app/utils/dead-refs.ts +0 -30
  115. package/layers/admin/app/utils/edit-form.ts +0 -267
  116. package/layers/admin/app/utils/editor-expose.ts +0 -36
  117. package/layers/admin/app/utils/editor-form-context.ts +0 -82
  118. package/layers/admin/app/utils/editor-registry.ts +0 -20
  119. package/layers/admin/app/utils/field-keys.ts +0 -11
  120. package/layers/admin/app/utils/list-cell.ts +0 -46
  121. package/layers/admin/app/utils/list-columns.ts +0 -142
  122. package/layers/admin/app/utils/list-query.ts +0 -96
  123. package/layers/admin/app/utils/nav-active.ts +0 -9
  124. package/layers/admin/app/utils/populate-blocks.ts +0 -288
  125. package/layers/admin/app/utils/preview-channel.ts +0 -62
  126. package/layers/admin/app/utils/preview-viewport.ts +0 -80
  127. package/layers/admin/app/utils/reauth.ts +0 -67
  128. package/layers/admin/app/utils/record-title.ts +0 -15
  129. package/layers/admin/app/utils/register-builtin-editors.ts +0 -9
  130. package/layers/admin/app/utils/safe-redirect.ts +0 -12
  131. package/layers/admin/nuxt.config.ts +0 -20
  132. package/layers/auth/nuxt.config.ts +0 -1
  133. package/layers/auth/server/plugins/01.register-auth-pipelines.ts +0 -7
  134. package/layers/collections/nuxt.config.ts +0 -1
  135. package/layers/collections/server/plugins/01.register.ts +0 -29
  136. package/layers/collections/server/plugins/02.register-relation-populate.ts +0 -18
  137. package/layers/core/app/composables/layouts.ts +0 -5
  138. package/layers/core/app/utils/field-factories.ts +0 -129
  139. package/layers/core/app/utils/layouts.ts +0 -38
  140. package/layers/core/modules/auto-discovery/extract-block.ts +0 -179
  141. package/layers/core/modules/auto-discovery/index.ts +0 -112
  142. package/layers/core/modules/auto-discovery/package-registry.ts +0 -19
  143. package/layers/core/modules/auto-discovery/scan.ts +0 -170
  144. package/layers/core/modules/auto-discovery/virtual.d.ts +0 -20
  145. package/layers/core/modules/component-namespace/index.ts +0 -44
  146. package/layers/core/modules/component-namespace/shared.ts +0 -24
  147. package/layers/core/modules/kestrel/app-shell.ts +0 -55
  148. package/layers/core/modules/kestrel/auto-imports.ts +0 -44
  149. package/layers/core/modules/kestrel/index.ts +0 -144
  150. package/layers/core/modules/plugin-order/index.ts +0 -69
  151. package/layers/core/modules/plugin-order/plugin-order.ts +0 -273
  152. package/layers/core/nuxt.config.ts +0 -10
  153. package/layers/core/server/api/[...path].ts +0 -98
  154. package/layers/core/server/plugins/00.config.ts +0 -12
  155. package/layers/core/server/plugins/00.migrate.ts +0 -12
  156. package/layers/core/server/plugins/01.register-introspection-pipeline.ts +0 -5
  157. package/layers/core/server/plugins/01.register-openapi-pipeline.ts +0 -5
  158. package/layers/core/server/plugins/01.register-tooling-pipelines.ts +0 -5
  159. package/layers/core/server/plugins/02.schema-sync.ts +0 -58
  160. package/layers/core/server/plugins/04.outbox-worker.ts +0 -31
  161. package/layers/core/server/plugins/05.reindex-refs.ts +0 -5
  162. package/layers/core/server/routes/__kestrel/dashboard.get.ts +0 -29
  163. package/layers/core/server/tasks/db/migrate-module.ts +0 -83
  164. package/layers/core/server/tasks/db/migrate-revisions.ts +0 -20
  165. package/layers/core/server/tasks/db/migrate.ts +0 -50
  166. package/layers/core/server/utils/server-config.ts +0 -81
  167. package/layers/fields/nuxt.config.ts +0 -1
  168. package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +0 -7
  169. package/layers/fields/server/plugins/01.register-field-populate.ts +0 -11
  170. package/layers/media/app/components/KestrelImg.vue +0 -94
  171. package/layers/media/app/components/MediaConflictDialog.vue +0 -35
  172. package/layers/media/app/components/MediaDeleteDialog.vue +0 -52
  173. package/layers/media/app/components/MediaGrid.vue +0 -113
  174. package/layers/media/app/components/MediaImage.vue +0 -70
  175. package/layers/media/app/components/MediaLibrary.vue +0 -456
  176. package/layers/media/app/components/MediaNewFolderDialog.vue +0 -32
  177. package/layers/media/app/components/MediaPathBar.vue +0 -163
  178. package/layers/media/app/components/MediaPicker.vue +0 -19
  179. package/layers/media/app/components/MediaRenameDialog.vue +0 -32
  180. package/layers/media/app/components/MediaTable.vue +0 -75
  181. package/layers/media/app/components/MediaThumb.vue +0 -25
  182. package/layers/media/app/components/MediaToolbar.vue +0 -90
  183. package/layers/media/app/components/MediaUploadDialog.vue +0 -87
  184. package/layers/media/app/components/MediaViewer.vue +0 -137
  185. package/layers/media/app/components/field/Media.vue +0 -263
  186. package/layers/media/app/composables/useBlurUp.ts +0 -55
  187. package/layers/media/app/composables/useMediaClipboard.ts +0 -14
  188. package/layers/media/app/composables/useMediaContextMenu.ts +0 -56
  189. package/layers/media/app/composables/useMediaDnd.ts +0 -56
  190. package/layers/media/app/composables/useMediaLibrary.ts +0 -152
  191. package/layers/media/app/composables/useMediaOps.ts +0 -50
  192. package/layers/media/app/composables/useMediaRelocate.ts +0 -39
  193. package/layers/media/app/composables/useMediaResolver.ts +0 -50
  194. package/layers/media/app/composables/useMediaUpload.ts +0 -93
  195. package/layers/media/app/composables/useMediaVariant.ts +0 -34
  196. package/layers/media/app/plugins/field-media.client.ts +0 -6
  197. package/layers/media/app/utils/ai-disclosure.ts +0 -19
  198. package/layers/media/app/utils/dnd.ts +0 -69
  199. package/layers/media/app/utils/library.ts +0 -81
  200. package/layers/media/app/utils/ops.ts +0 -60
  201. package/layers/media/app/utils/picture-model.ts +0 -125
  202. package/layers/media/app/utils/thumbhash.ts +0 -19
  203. package/layers/media/app/utils/variant-name.ts +0 -20
  204. package/layers/media/nuxt.config.ts +0 -7
  205. package/layers/media/server/middleware/ondemand-variants.ts +0 -38
  206. package/layers/media/server/plugins/01.register-media-pipelines.ts +0 -7
  207. package/layers/media/server/plugins/02.register-media.ts +0 -22
  208. package/layers/media/server/plugins/04.variant-capture.ts +0 -27
  209. package/layers/media/server/plugins/05.media-cleanup.ts +0 -5
  210. package/layers/media/server/tasks/media/backfill.ts +0 -17
  211. package/layers/public/app/app.vue +0 -5
  212. package/layers/public/app/components/BlockRenderer.vue +0 -56
  213. package/layers/public/app/components/KestrelLink.vue +0 -26
  214. package/layers/public/app/components/KestrelPreviewBridge.vue +0 -133
  215. package/layers/public/app/composables/public-page.ts +0 -24
  216. package/layers/public/app/error.vue +0 -12
  217. package/layers/public/app/layouts/default.vue +0 -5
  218. package/layers/public/app/pages/[...slug].vue +0 -239
  219. package/layers/public/app/pages/__kestrel/preview.vue +0 -46
  220. package/layers/public/app/utils/block-edit-context.ts +0 -16
  221. package/layers/public/app/utils/json-ld.ts +0 -139
  222. package/layers/public/app/utils/link-href.ts +0 -46
  223. package/layers/public/app/utils/page-head.ts +0 -84
  224. package/layers/public/app/utils/page-layout.ts +0 -18
  225. package/layers/public/app/utils/preview-protocol.ts +0 -117
  226. package/layers/public/app/utils/public-route.ts +0 -46
  227. package/layers/public/app/utils/site-head.ts +0 -43
  228. package/layers/public/modules/deploy-output/index.ts +0 -80
  229. package/layers/public/modules/prerender-routes/discover.ts +0 -37
  230. package/layers/public/modules/prerender-routes/index.ts +0 -88
  231. package/layers/public/modules/prerender-routes/routes.ts +0 -16
  232. package/layers/public/modules/prune-media/index.ts +0 -48
  233. package/layers/public/modules/prune-media/prune-media.ts +0 -81
  234. package/layers/public/nuxt.config.ts +0 -14
  235. package/layers/public/server/delivery-live/serve.ts +0 -111
  236. package/layers/public/server/middleware/delivery-live-catchall.ts +0 -7
  237. package/layers/public/server/pipelines/index.ts +0 -23
  238. package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +0 -14
  239. package/layers/public/server/plugins/01.register-public-pipelines.ts +0 -7
  240. package/layers/public/server/plugins/02.register-links.ts +0 -16
  241. package/layers/public/server/plugins/03.redirects.ts +0 -55
  242. package/layers/public/server/plugins/05.plan-publish.ts +0 -5
  243. package/layers/public/server/plugins/zz.publish.ts +0 -127
  244. package/layers/public/server/routes/llms-full.txt.get.ts +0 -99
  245. package/layers/public/server/routes/llms.txt.get.ts +0 -66
  246. package/layers/public/server/routes/redirects.json.get.ts +0 -58
  247. package/layers/public/server/routes/robots.txt.get.ts +0 -11
  248. package/layers/public/server/routes/sitemap.xml.get.ts +0 -79
  249. package/layers/public/server/tasks/publish/run.ts +0 -32
  250. package/layers/public/server/utils/publish/render-live.ts +0 -26
  251. package/layers/ui/app/assets/scss/_base.scss +0 -16
  252. package/layers/ui/app/assets/scss/_datepicker.scss +0 -82
  253. package/layers/ui/app/assets/scss/_mixins.scss +0 -50
  254. package/layers/ui/app/assets/scss/_reset.scss +0 -65
  255. package/layers/ui/app/assets/scss/_tokens.scss +0 -183
  256. package/layers/ui/app/assets/scss/main.scss +0 -4
  257. package/layers/ui/app/components/field/Boolean.vue +0 -30
  258. package/layers/ui/app/components/field/Choice.vue +0 -73
  259. package/layers/ui/app/components/field/Datetime.vue +0 -98
  260. package/layers/ui/app/components/field/DeadRefNote.vue +0 -28
  261. package/layers/ui/app/components/field/Json.vue +0 -62
  262. package/layers/ui/app/components/field/Layout.vue +0 -138
  263. package/layers/ui/app/components/field/Link.vue +0 -112
  264. package/layers/ui/app/components/field/LinkInternalPicker.vue +0 -95
  265. package/layers/ui/app/components/field/Number.vue +0 -30
  266. package/layers/ui/app/components/field/Relation.vue +0 -49
  267. package/layers/ui/app/components/field/Renderer.vue +0 -30
  268. package/layers/ui/app/components/field/Repeater.vue +0 -460
  269. package/layers/ui/app/components/field/Richtext.vue +0 -26
  270. package/layers/ui/app/components/field/Slug.vue +0 -65
  271. package/layers/ui/app/components/field/Text.vue +0 -35
  272. package/layers/ui/app/components/field/Unsupported.vue +0 -34
  273. package/layers/ui/app/components/ui/Alert.vue +0 -50
  274. package/layers/ui/app/components/ui/Brand.vue +0 -22
  275. package/layers/ui/app/components/ui/Button.vue +0 -116
  276. package/layers/ui/app/components/ui/ButtonGroup.vue +0 -96
  277. package/layers/ui/app/components/ui/Checkbox.vue +0 -53
  278. package/layers/ui/app/components/ui/CheckboxGroup.vue +0 -51
  279. package/layers/ui/app/components/ui/Combobox.vue +0 -345
  280. package/layers/ui/app/components/ui/DatePicker.vue +0 -92
  281. package/layers/ui/app/components/ui/DateRangePicker.vue +0 -100
  282. package/layers/ui/app/components/ui/Dialog.vue +0 -107
  283. package/layers/ui/app/components/ui/EmptyState.vue +0 -46
  284. package/layers/ui/app/components/ui/Field.vue +0 -58
  285. package/layers/ui/app/components/ui/Fieldset.vue +0 -65
  286. package/layers/ui/app/components/ui/Icon.vue +0 -44
  287. package/layers/ui/app/components/ui/Menu.vue +0 -54
  288. package/layers/ui/app/components/ui/NumberInput.vue +0 -175
  289. package/layers/ui/app/components/ui/Richtext.vue +0 -141
  290. package/layers/ui/app/components/ui/RichtextToolbar.vue +0 -251
  291. package/layers/ui/app/components/ui/Select.vue +0 -52
  292. package/layers/ui/app/components/ui/TextInput.vue +0 -111
  293. package/layers/ui/app/components/ui/Textarea.vue +0 -39
  294. package/layers/ui/app/components/ui/TimeInput.vue +0 -18
  295. package/layers/ui/app/components/ui/Toasts.vue +0 -103
  296. package/layers/ui/app/components/ui/Tooltip.vue +0 -45
  297. package/layers/ui/app/composables/useAdminLang.ts +0 -8
  298. package/layers/ui/app/composables/useDragReorder.ts +0 -41
  299. package/layers/ui/app/composables/useEchoGuard.ts +0 -28
  300. package/layers/ui/app/composables/useLinkField.ts +0 -70
  301. package/layers/ui/app/composables/useRecordOptions.ts +0 -84
  302. package/layers/ui/app/composables/useRepeater.ts +0 -99
  303. package/layers/ui/app/composables/useT.ts +0 -32
  304. package/layers/ui/app/composables/useToast.ts +0 -46
  305. package/layers/ui/app/i18n/de.ts +0 -432
  306. package/layers/ui/app/i18n/en.ts +0 -434
  307. package/layers/ui/app/utils/boolean-field.ts +0 -10
  308. package/layers/ui/app/utils/field-component.ts +0 -22
  309. package/layers/ui/app/utils/field-empty.ts +0 -21
  310. package/layers/ui/app/utils/field-registry.ts +0 -36
  311. package/layers/ui/app/utils/humanize.ts +0 -14
  312. package/layers/ui/app/utils/icons.ts +0 -143
  313. package/layers/ui/app/utils/iso-date.ts +0 -18
  314. package/layers/ui/app/utils/localized.ts +0 -13
  315. package/layers/ui/app/utils/record-context.ts +0 -15
  316. package/layers/ui/app/utils/reorder.ts +0 -8
  317. package/layers/ui/app/utils/richtext-preserve-class.ts +0 -31
  318. package/layers/ui/app/utils/useFieldA11y.ts +0 -40
  319. package/layers/ui/nuxt.config.ts +0 -9
  320. package/nuxt.config.ts +0 -108
  321. package/scripts/consumer-template-ci.mjs +0 -160
  322. package/scripts/copy-create-payload.mjs +0 -49
  323. package/scripts/coverage-all.mjs +0 -163
  324. package/scripts/dashboard.mjs +0 -89
  325. package/scripts/hash-password.mjs +0 -29
  326. package/scripts/kestrel.mjs +0 -240
  327. package/scripts/lib/cli.mjs +0 -129
  328. package/scripts/lib/coverage-model.mjs +0 -51
  329. package/scripts/lib/password.mjs +0 -19
  330. package/scripts/lib/scaffold.mjs +0 -205
  331. package/scripts/port-graph.mjs +0 -350
  332. package/scripts/typecheck.mjs +0 -62
  333. package/templates/starter/README.md +0 -65
  334. package/templates/starter/_env.example +0 -17
  335. package/templates/starter/_gitignore +0 -26
  336. package/templates/starter/_package.json +0 -22
  337. package/templates/starter/app/app.vue +0 -7
  338. package/templates/starter/app/blocks/Prose.vue +0 -13
  339. package/templates/starter/app/layouts/default.vue +0 -6
  340. package/templates/starter/nuxt.config.ts +0 -23
  341. package/templates/starter/pnpm-workspace.yaml +0 -8
  342. package/templates/starter/tsconfig.json +0 -3
@@ -1,263 +0,0 @@
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 '@michaelthielemann/kestrel-core'
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
- <KestrelUiField :id="id" :label="name" :error="error" :required="required">
118
- <template #default="f">
119
- <div class="field-media">
120
- <!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -- @dragleave is a mouse-only progressive enhancement; the move-earlier/move-later buttons below give the same reorder fully keyboard access -->
121
- <ul v-if="resolved.length" ref="itemsEl" class="field-media__items" @dragleave="onDragLeave">
122
- <!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -- drag handlers are a mouse-only progressive enhancement; the move-earlier/move-later buttons below give the same reorder fully keyboard access -->
123
- <li
124
- v-for="(r, i) in resolved"
125
- :key="r.id"
126
- class="field-media__item"
127
- :class="{
128
- 'field-media__item--draggable': multiple && !disabled,
129
- 'field-media__item--over': overIndex === i && dragIndex !== i,
130
- }"
131
- :draggable="multiple && !disabled"
132
- @dragstart="onDragStart(i, $event)"
133
- @dragenter="onDragEnter(i)"
134
- @dragover.prevent="onDragEnter(i)"
135
- @drop.prevent="onDrop(i)"
136
- @dragend="onDragEnd"
137
- >
138
- <KestrelMediaThumb v-if="isImage(r)" draggable="false" class="field-media__thumb" :src="r.src" :srcset="srcsetOf(r)" sizes="80px" :alt="r.alt ?? ''" :thumbhash="r.thumbhash" />
139
- <a v-else draggable="false" class="field-media__file" :href="r.src" target="_blank" rel="noopener noreferrer" :title="filenameOf(r)">
140
- <span class="field-media__badge" aria-hidden="true">{{ fileExt(r) }}</span>
141
- <span class="field-media__filename">{{ filenameOf(r) }}</span>
142
- </a>
143
- <template v-if="multiple && !disabled">
144
- <button
145
- type="button"
146
- draggable="false"
147
- class="field-media__move field-media__move--prev"
148
- :aria-label="t('field.media.move_earlier', { name: nameOf(r, i) })"
149
- :disabled="i === 0"
150
- @click="moveItem(i, -1)"
151
- ><KestrelUiIcon name="chevron-left" size="0.875rem" /></button>
152
- <button
153
- type="button"
154
- draggable="false"
155
- class="field-media__move field-media__move--next"
156
- :aria-label="t('field.media.move_later', { name: nameOf(r, i) })"
157
- :disabled="i === resolved.length - 1"
158
- @click="moveItem(i, 1)"
159
- ><KestrelUiIcon name="chevron-right" size="0.875rem" /></button>
160
- </template>
161
- <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)"><KestrelUiIcon name="x" size="1rem" /></button>
162
- </li>
163
- </ul>
164
- <p v-else class="field-media__empty">{{ t('field.media.empty') }}</p>
165
- <KestrelUiButton
166
- v-if="!disabled"
167
- :id="f.id"
168
- type="button"
169
- variant="secondary"
170
- :aria-invalid="f['aria-invalid']"
171
- :aria-describedby="f['aria-describedby']"
172
- @click="pickerOpen = true"
173
- >
174
- {{ multiple ? t('field.media.add') : (resolved.length ? t('field.media.replace') : t('field.media.select')) }}
175
- </KestrelUiButton>
176
- <span class="field-media__live" aria-live="polite">{{ liveMessage }}</span>
177
- </div>
178
- <KestrelMediaPicker v-model:open="pickerOpen" :multiple="multiple" :accept="accept" :initial-folder="initialFolder" :initial-selected="ids" @confirm="onConfirm" />
179
- </template>
180
- </KestrelUiField>
181
- </template>
182
-
183
- <style lang="scss" scoped>
184
- .field-media { display: flex; flex-direction: column; gap: var(--space-2); align-items: flex-start; }
185
- .field-media__items { display: flex; flex-wrap: wrap; gap: var(--space-2); list-style: none; margin: 0; padding: 0; }
186
- .field-media__item { position: relative; }
187
- .field-media__item--draggable { cursor: grab; }
188
- .field-media__item--draggable:active { cursor: grabbing; }
189
- /* Drop indicator — outward outline (items are gap-spaced, never overflow-clipped). */
190
- .field-media__item--over { outline: 2px solid var(--color-primary); outline-offset: 2px; border-radius: var(--radius-md); }
191
- .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); }
192
- .field-media__file { display: flex; flex-direction: column; gap: var(--space-1); width: 80px; text-decoration: none; color: inherit; }
193
- .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); }
194
- .field-media__filename { max-width: 80px; font-size: var(--text-xs); color: var(--color-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
195
-
196
- /* Remove control — a dark translucent disc + white glyph reads on ANY image (a white surface disc was
197
- invisible over pale photos); the light ring lifts it off, hover escalates to solid danger. */
198
- .field-media__remove {
199
- position: absolute;
200
- top: 4px;
201
- right: 4px;
202
- width: 1.75rem;
203
- height: 1.75rem;
204
- display: grid;
205
- place-items: center;
206
- padding: 0;
207
- border: 0;
208
- border-radius: var(--radius-full);
209
- background: rgba(9, 9, 11, 0.55);
210
- color: #fff;
211
- box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.55), var(--shadow-sm);
212
- cursor: pointer;
213
- line-height: 1;
214
- transition: background var(--motion-fast) var(--ease-standard),
215
- transform var(--motion-fast) var(--ease-standard),
216
- box-shadow var(--motion-fast) var(--ease-standard);
217
- }
218
- .field-media__remove:hover {
219
- background: var(--color-danger-solid);
220
- color: var(--color-on-danger);
221
- box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.7), var(--shadow-md);
222
- transform: scale(1.08);
223
- }
224
- .field-media__remove:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
225
-
226
- /* Keyboard reorder buttons — same disc idiom, revealed on hover/focus so idle thumbs stay clean.
227
- Kept focusable when idle (opacity, not display:none) so Tab reveals them via :focus-within. */
228
- .field-media__move {
229
- position: absolute;
230
- bottom: 4px;
231
- width: 1.5rem;
232
- height: 1.5rem;
233
- display: grid;
234
- place-items: center;
235
- padding: 0;
236
- border: 0;
237
- border-radius: var(--radius-full);
238
- background: rgba(9, 9, 11, 0.55);
239
- color: #fff;
240
- box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.55);
241
- cursor: pointer;
242
- line-height: 1;
243
- opacity: 0;
244
- transition: opacity var(--motion-fast) var(--ease-standard);
245
- }
246
- .field-media__move--prev { left: 4px; }
247
- .field-media__move--next { right: 4px; }
248
- .field-media__move:disabled { cursor: default; } /* stays hidden at a boundary (reveal excludes :disabled) */
249
- .field-media__item:hover .field-media__move:not(:disabled),
250
- .field-media__item:focus-within .field-media__move:not(:disabled) { opacity: 1; }
251
- .field-media__move:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 2px; }
252
- @media (hover: none) {
253
- .field-media__move:not(:disabled) { opacity: 1; }
254
- }
255
- @media (prefers-reduced-motion: reduce) {
256
- .field-media__remove,
257
- .field-media__move { transition: none; }
258
- .field-media__remove:hover { transform: none; }
259
- }
260
-
261
- .field-media__empty { color: var(--color-text-muted); font-size: var(--text-sm); margin: 0; }
262
- .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; }
263
- </style>
@@ -1,55 +0,0 @@
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
- }
@@ -1,14 +0,0 @@
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
- }
@@ -1,56 +0,0 @@
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
- }
@@ -1,56 +0,0 @@
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
- }
@@ -1,152 +0,0 @@
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
- if (s.has(k)) s.delete(k)
123
- else s.add(k)
124
- selected.value = s; anchorKey = k
125
- }
126
- function range(item: LibraryItem) { selected.value = computeRange(orderedKeys.value, anchorKey ?? itemKey(item), itemKey(item), selected.value) }
127
- function clear() { selected.value = new Set(); anchorKey = null }
128
- function selectAll() { selected.value = new Set(orderedKeys.value) }
129
-
130
- if (urlSync) {
131
- watch(() => route.query.folder, (q) => {
132
- const next = typeof q === 'string' ? q : ''
133
- if (next === folder.value) return
134
- clearTimeout(searchTimer)
135
- search.value = ''
136
- folder.value = next
137
- page.value = 1
138
- clear()
139
- fetchLibrary()
140
- })
141
- }
142
-
143
- onMounted(() => fetchLibrary())
144
- onUnmounted(() => clearTimeout(searchTimer))
145
-
146
- return {
147
- folder, folders, files, total, page, search, sort, view, loading, error,
148
- items, parentPath, orderedKeys, hasMore, count, selected,
149
- fetchLibrary, loadMore, navigate, setSearch, setView, setSort,
150
- isSelected, select, toggle, range, clear, selectAll,
151
- }
152
- }
@@ -1,50 +0,0 @@
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
- }