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