@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.
- package/README.md +26 -201
- package/dist/boot.d.ts +45 -0
- package/dist/boot.js +284 -0
- package/dist/cast.d.ts +3 -0
- package/dist/cast.js +6 -0
- package/dist/catalogue.d.ts +11 -0
- package/dist/catalogue.js +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +55 -0
- package/dist/context.d.ts +62 -0
- package/dist/context.js +61 -0
- package/dist/dataflow.d.ts +3 -0
- package/dist/dataflow.js +65 -0
- package/dist/defineConfig.d.ts +162 -0
- package/dist/defineConfig.js +42 -0
- package/dist/defineContract.d.ts +12 -0
- package/dist/defineContract.js +16 -0
- package/dist/defineModule.d.ts +75 -0
- package/dist/defineModule.js +9 -0
- package/dist/definePipeline.d.ts +7 -0
- package/dist/definePipeline.js +11 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.js +48 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +14 -0
- package/dist/load.d.ts +7 -0
- package/dist/load.js +79 -0
- package/dist/logger.d.ts +18 -0
- package/dist/logger.js +36 -0
- package/dist/registry.d.ts +14 -0
- package/dist/registry.js +87 -0
- package/dist/result.d.ts +19 -0
- package/dist/result.js +19 -0
- package/dist/runner.d.ts +29 -0
- package/dist/runner.js +133 -0
- package/dist/schema.d.ts +7 -0
- package/dist/schema.js +142 -0
- package/dist/sort.d.ts +2 -0
- package/dist/sort.js +50 -0
- package/dist/testing/runPipeline.d.ts +9 -0
- package/dist/testing/runPipeline.js +22 -0
- package/dist/triggers/allowlist.d.ts +9 -0
- package/dist/triggers/allowlist.js +49 -0
- package/dist/triggers/cron.d.ts +10 -0
- package/dist/triggers/cron.js +44 -0
- package/dist/triggers/cronMatch.d.ts +10 -0
- package/dist/triggers/cronMatch.js +68 -0
- package/dist/triggers/http.d.ts +59 -0
- package/dist/triggers/http.js +300 -0
- package/package.json +25 -151
- package/NOTICE +0 -12
- package/kestrel.config.ts +0 -28
- package/layers/access/nuxt.config.ts +0 -5
- package/layers/access/server/middleware/00.ip-allowlist.ts +0 -52
- package/layers/access/server/middleware/access-guard.ts +0 -32
- package/layers/access/server/types.d.ts +0 -7
- package/layers/admin/app/components/AdminAccount.vue +0 -212
- package/layers/admin/app/components/AdminNav.vue +0 -44
- package/layers/admin/app/components/BlockFields.vue +0 -67
- package/layers/admin/app/components/BlockPreview.vue +0 -413
- package/layers/admin/app/components/BlockTree.vue +0 -433
- package/layers/admin/app/components/BlocksBody.vue +0 -163
- package/layers/admin/app/components/CollectionDeleteDialog.vue +0 -38
- package/layers/admin/app/components/CollectionEditor.vue +0 -324
- package/layers/admin/app/components/CollectionList.vue +0 -429
- package/layers/admin/app/components/CollectionListBulkBar.vue +0 -62
- package/layers/admin/app/components/CollectionListColumnsPanel.vue +0 -37
- package/layers/admin/app/components/CollectionListFilterPanel.vue +0 -145
- package/layers/admin/app/components/CollectionListPager.vue +0 -70
- package/layers/admin/app/components/CollectionListTable.vue +0 -347
- package/layers/admin/app/components/EditorStatus.vue +0 -211
- package/layers/admin/app/components/EditorUnsupported.vue +0 -28
- package/layers/admin/app/components/FieldsBody.vue +0 -23
- package/layers/admin/app/components/LocaleBar.vue +0 -107
- package/layers/admin/app/components/PageFields.vue +0 -129
- package/layers/admin/app/components/PageFieldsPane.vue +0 -39
- package/layers/admin/app/components/SeoFields.vue +0 -210
- package/layers/admin/app/components/SingletonEditor.vue +0 -93
- package/layers/admin/app/composables/useAuth.ts +0 -68
- package/layers/admin/app/composables/useBlockTree.ts +0 -95
- package/layers/admin/app/composables/useBlocks.ts +0 -7
- package/layers/admin/app/composables/useCachedResource.ts +0 -35
- package/layers/admin/app/composables/useCollectionOps.ts +0 -102
- package/layers/admin/app/composables/useCollections.ts +0 -6
- package/layers/admin/app/composables/useContentLocales.ts +0 -23
- package/layers/admin/app/composables/useEditForm.ts +0 -402
- package/layers/admin/app/composables/useLinkResolver.ts +0 -40
- package/layers/admin/app/composables/useListBatchActions.ts +0 -78
- package/layers/admin/app/composables/useListColumns.ts +0 -53
- package/layers/admin/app/composables/useListFilterDraft.ts +0 -129
- package/layers/admin/app/composables/useListRows.ts +0 -75
- package/layers/admin/app/composables/useListSelection.ts +0 -28
- package/layers/admin/app/composables/useListUrlState.ts +0 -53
- package/layers/admin/app/composables/usePendingPublishPoll.ts +0 -15
- package/layers/admin/app/composables/usePreviewViewport.ts +0 -30
- package/layers/admin/app/composables/usePublishStatus.ts +0 -107
- package/layers/admin/app/composables/useRailCollapsed.ts +0 -14
- package/layers/admin/app/composables/useTheme.ts +0 -36
- package/layers/admin/app/composables/useToolbarPanel.ts +0 -38
- package/layers/admin/app/composables/useUnsavedGuard.ts +0 -22
- package/layers/admin/app/layouts/admin.vue +0 -266
- package/layers/admin/app/middleware/admin-auth.ts +0 -4
- package/layers/admin/app/pages/admin/[collection]/[id].vue +0 -203
- package/layers/admin/app/pages/admin/[collection]/index.vue +0 -62
- package/layers/admin/app/pages/admin/index.vue +0 -118
- package/layers/admin/app/pages/admin/login.vue +0 -68
- package/layers/admin/app/pages/admin/media.vue +0 -28
- package/layers/admin/app/pages/admin/references.vue +0 -146
- package/layers/admin/app/plugins/admin-style-guard.client.ts +0 -76
- package/layers/admin/app/plugins/reauth.client.ts +0 -20
- package/layers/admin/app/utils/admin-style-guard.ts +0 -20
- package/layers/admin/app/utils/block-tree.ts +0 -214
- package/layers/admin/app/utils/collection-ops.ts +0 -69
- package/layers/admin/app/utils/dead-refs.ts +0 -30
- package/layers/admin/app/utils/edit-form.ts +0 -267
- package/layers/admin/app/utils/editor-expose.ts +0 -36
- package/layers/admin/app/utils/editor-form-context.ts +0 -82
- package/layers/admin/app/utils/editor-registry.ts +0 -20
- package/layers/admin/app/utils/field-keys.ts +0 -11
- package/layers/admin/app/utils/list-cell.ts +0 -46
- package/layers/admin/app/utils/list-columns.ts +0 -142
- package/layers/admin/app/utils/list-query.ts +0 -96
- package/layers/admin/app/utils/nav-active.ts +0 -9
- package/layers/admin/app/utils/populate-blocks.ts +0 -288
- package/layers/admin/app/utils/preview-channel.ts +0 -62
- package/layers/admin/app/utils/preview-viewport.ts +0 -80
- package/layers/admin/app/utils/reauth.ts +0 -67
- package/layers/admin/app/utils/record-title.ts +0 -15
- package/layers/admin/app/utils/register-builtin-editors.ts +0 -9
- package/layers/admin/app/utils/safe-redirect.ts +0 -12
- package/layers/admin/nuxt.config.ts +0 -20
- package/layers/auth/nuxt.config.ts +0 -1
- package/layers/auth/server/plugins/01.register-auth-pipelines.ts +0 -7
- package/layers/collections/nuxt.config.ts +0 -1
- package/layers/collections/server/plugins/01.register.ts +0 -29
- package/layers/collections/server/plugins/02.register-relation-populate.ts +0 -18
- package/layers/core/app/composables/layouts.ts +0 -5
- package/layers/core/app/utils/field-factories.ts +0 -129
- package/layers/core/app/utils/layouts.ts +0 -38
- package/layers/core/modules/auto-discovery/extract-block.ts +0 -179
- package/layers/core/modules/auto-discovery/index.ts +0 -112
- package/layers/core/modules/auto-discovery/package-registry.ts +0 -19
- package/layers/core/modules/auto-discovery/scan.ts +0 -170
- package/layers/core/modules/auto-discovery/virtual.d.ts +0 -20
- package/layers/core/modules/component-namespace/index.ts +0 -44
- package/layers/core/modules/component-namespace/shared.ts +0 -24
- package/layers/core/modules/kestrel/app-shell.ts +0 -55
- package/layers/core/modules/kestrel/auto-imports.ts +0 -44
- package/layers/core/modules/kestrel/index.ts +0 -144
- package/layers/core/modules/plugin-order/index.ts +0 -69
- package/layers/core/modules/plugin-order/plugin-order.ts +0 -273
- package/layers/core/nuxt.config.ts +0 -10
- package/layers/core/server/api/[...path].ts +0 -98
- package/layers/core/server/plugins/00.config.ts +0 -12
- package/layers/core/server/plugins/00.migrate.ts +0 -12
- package/layers/core/server/plugins/01.register-introspection-pipeline.ts +0 -5
- package/layers/core/server/plugins/01.register-openapi-pipeline.ts +0 -5
- package/layers/core/server/plugins/01.register-tooling-pipelines.ts +0 -5
- package/layers/core/server/plugins/02.schema-sync.ts +0 -58
- package/layers/core/server/plugins/04.outbox-worker.ts +0 -31
- package/layers/core/server/plugins/05.reindex-refs.ts +0 -5
- package/layers/core/server/routes/__kestrel/dashboard.get.ts +0 -29
- package/layers/core/server/tasks/db/migrate-module.ts +0 -83
- package/layers/core/server/tasks/db/migrate-revisions.ts +0 -20
- package/layers/core/server/tasks/db/migrate.ts +0 -50
- package/layers/core/server/utils/server-config.ts +0 -81
- package/layers/fields/nuxt.config.ts +0 -1
- package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +0 -7
- package/layers/fields/server/plugins/01.register-field-populate.ts +0 -11
- package/layers/media/app/components/KestrelImg.vue +0 -94
- package/layers/media/app/components/MediaConflictDialog.vue +0 -35
- package/layers/media/app/components/MediaDeleteDialog.vue +0 -52
- package/layers/media/app/components/MediaGrid.vue +0 -113
- package/layers/media/app/components/MediaImage.vue +0 -70
- package/layers/media/app/components/MediaLibrary.vue +0 -456
- package/layers/media/app/components/MediaNewFolderDialog.vue +0 -32
- package/layers/media/app/components/MediaPathBar.vue +0 -163
- package/layers/media/app/components/MediaPicker.vue +0 -19
- package/layers/media/app/components/MediaRenameDialog.vue +0 -32
- package/layers/media/app/components/MediaTable.vue +0 -75
- package/layers/media/app/components/MediaThumb.vue +0 -25
- package/layers/media/app/components/MediaToolbar.vue +0 -90
- package/layers/media/app/components/MediaUploadDialog.vue +0 -87
- package/layers/media/app/components/MediaViewer.vue +0 -137
- package/layers/media/app/components/field/Media.vue +0 -263
- package/layers/media/app/composables/useBlurUp.ts +0 -55
- package/layers/media/app/composables/useMediaClipboard.ts +0 -14
- package/layers/media/app/composables/useMediaContextMenu.ts +0 -56
- package/layers/media/app/composables/useMediaDnd.ts +0 -56
- package/layers/media/app/composables/useMediaLibrary.ts +0 -152
- package/layers/media/app/composables/useMediaOps.ts +0 -50
- package/layers/media/app/composables/useMediaRelocate.ts +0 -39
- package/layers/media/app/composables/useMediaResolver.ts +0 -50
- package/layers/media/app/composables/useMediaUpload.ts +0 -93
- package/layers/media/app/composables/useMediaVariant.ts +0 -34
- package/layers/media/app/plugins/field-media.client.ts +0 -6
- package/layers/media/app/utils/ai-disclosure.ts +0 -19
- package/layers/media/app/utils/dnd.ts +0 -69
- package/layers/media/app/utils/library.ts +0 -81
- package/layers/media/app/utils/ops.ts +0 -60
- package/layers/media/app/utils/picture-model.ts +0 -125
- package/layers/media/app/utils/thumbhash.ts +0 -19
- package/layers/media/app/utils/variant-name.ts +0 -20
- package/layers/media/nuxt.config.ts +0 -7
- package/layers/media/server/middleware/ondemand-variants.ts +0 -38
- package/layers/media/server/plugins/01.register-media-pipelines.ts +0 -7
- package/layers/media/server/plugins/02.register-media.ts +0 -22
- package/layers/media/server/plugins/04.variant-capture.ts +0 -27
- package/layers/media/server/plugins/05.media-cleanup.ts +0 -5
- package/layers/media/server/tasks/media/backfill.ts +0 -17
- package/layers/public/app/app.vue +0 -5
- package/layers/public/app/components/BlockRenderer.vue +0 -56
- package/layers/public/app/components/KestrelLink.vue +0 -26
- package/layers/public/app/components/KestrelPreviewBridge.vue +0 -133
- package/layers/public/app/composables/public-page.ts +0 -24
- package/layers/public/app/error.vue +0 -12
- package/layers/public/app/layouts/default.vue +0 -5
- package/layers/public/app/pages/[...slug].vue +0 -239
- package/layers/public/app/pages/__kestrel/preview.vue +0 -46
- package/layers/public/app/utils/block-edit-context.ts +0 -16
- package/layers/public/app/utils/json-ld.ts +0 -139
- package/layers/public/app/utils/link-href.ts +0 -46
- package/layers/public/app/utils/page-head.ts +0 -84
- package/layers/public/app/utils/page-layout.ts +0 -18
- package/layers/public/app/utils/preview-protocol.ts +0 -117
- package/layers/public/app/utils/public-route.ts +0 -46
- package/layers/public/app/utils/site-head.ts +0 -43
- package/layers/public/modules/deploy-output/index.ts +0 -80
- package/layers/public/modules/prerender-routes/discover.ts +0 -37
- package/layers/public/modules/prerender-routes/index.ts +0 -88
- package/layers/public/modules/prerender-routes/routes.ts +0 -16
- package/layers/public/modules/prune-media/index.ts +0 -48
- package/layers/public/modules/prune-media/prune-media.ts +0 -81
- package/layers/public/nuxt.config.ts +0 -14
- package/layers/public/server/delivery-live/serve.ts +0 -111
- package/layers/public/server/middleware/delivery-live-catchall.ts +0 -7
- package/layers/public/server/pipelines/index.ts +0 -23
- package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +0 -14
- package/layers/public/server/plugins/01.register-public-pipelines.ts +0 -7
- package/layers/public/server/plugins/02.register-links.ts +0 -16
- package/layers/public/server/plugins/03.redirects.ts +0 -55
- package/layers/public/server/plugins/05.plan-publish.ts +0 -5
- package/layers/public/server/plugins/zz.publish.ts +0 -127
- package/layers/public/server/routes/llms-full.txt.get.ts +0 -99
- package/layers/public/server/routes/llms.txt.get.ts +0 -66
- package/layers/public/server/routes/redirects.json.get.ts +0 -58
- package/layers/public/server/routes/robots.txt.get.ts +0 -11
- package/layers/public/server/routes/sitemap.xml.get.ts +0 -79
- package/layers/public/server/tasks/publish/run.ts +0 -32
- package/layers/public/server/utils/publish/render-live.ts +0 -26
- package/layers/ui/app/assets/scss/_base.scss +0 -16
- package/layers/ui/app/assets/scss/_datepicker.scss +0 -82
- package/layers/ui/app/assets/scss/_mixins.scss +0 -50
- package/layers/ui/app/assets/scss/_reset.scss +0 -65
- package/layers/ui/app/assets/scss/_tokens.scss +0 -183
- package/layers/ui/app/assets/scss/main.scss +0 -4
- package/layers/ui/app/components/field/Boolean.vue +0 -30
- package/layers/ui/app/components/field/Choice.vue +0 -73
- package/layers/ui/app/components/field/Datetime.vue +0 -98
- package/layers/ui/app/components/field/DeadRefNote.vue +0 -28
- package/layers/ui/app/components/field/Json.vue +0 -62
- package/layers/ui/app/components/field/Layout.vue +0 -138
- package/layers/ui/app/components/field/Link.vue +0 -112
- package/layers/ui/app/components/field/LinkInternalPicker.vue +0 -95
- package/layers/ui/app/components/field/Number.vue +0 -30
- package/layers/ui/app/components/field/Relation.vue +0 -49
- package/layers/ui/app/components/field/Renderer.vue +0 -30
- package/layers/ui/app/components/field/Repeater.vue +0 -460
- package/layers/ui/app/components/field/Richtext.vue +0 -26
- package/layers/ui/app/components/field/Slug.vue +0 -65
- package/layers/ui/app/components/field/Text.vue +0 -35
- package/layers/ui/app/components/field/Unsupported.vue +0 -34
- package/layers/ui/app/components/ui/Alert.vue +0 -50
- package/layers/ui/app/components/ui/Brand.vue +0 -22
- package/layers/ui/app/components/ui/Button.vue +0 -116
- package/layers/ui/app/components/ui/ButtonGroup.vue +0 -96
- package/layers/ui/app/components/ui/Checkbox.vue +0 -53
- package/layers/ui/app/components/ui/CheckboxGroup.vue +0 -51
- package/layers/ui/app/components/ui/Combobox.vue +0 -345
- package/layers/ui/app/components/ui/DatePicker.vue +0 -92
- package/layers/ui/app/components/ui/DateRangePicker.vue +0 -100
- package/layers/ui/app/components/ui/Dialog.vue +0 -107
- package/layers/ui/app/components/ui/EmptyState.vue +0 -46
- package/layers/ui/app/components/ui/Field.vue +0 -58
- package/layers/ui/app/components/ui/Fieldset.vue +0 -65
- package/layers/ui/app/components/ui/Icon.vue +0 -44
- package/layers/ui/app/components/ui/Menu.vue +0 -54
- package/layers/ui/app/components/ui/NumberInput.vue +0 -175
- package/layers/ui/app/components/ui/Richtext.vue +0 -141
- package/layers/ui/app/components/ui/RichtextToolbar.vue +0 -251
- package/layers/ui/app/components/ui/Select.vue +0 -52
- package/layers/ui/app/components/ui/TextInput.vue +0 -111
- package/layers/ui/app/components/ui/Textarea.vue +0 -39
- package/layers/ui/app/components/ui/TimeInput.vue +0 -18
- package/layers/ui/app/components/ui/Toasts.vue +0 -103
- package/layers/ui/app/components/ui/Tooltip.vue +0 -45
- package/layers/ui/app/composables/useAdminLang.ts +0 -8
- package/layers/ui/app/composables/useDragReorder.ts +0 -41
- package/layers/ui/app/composables/useEchoGuard.ts +0 -28
- package/layers/ui/app/composables/useLinkField.ts +0 -70
- package/layers/ui/app/composables/useRecordOptions.ts +0 -84
- package/layers/ui/app/composables/useRepeater.ts +0 -99
- package/layers/ui/app/composables/useT.ts +0 -32
- package/layers/ui/app/composables/useToast.ts +0 -46
- package/layers/ui/app/i18n/de.ts +0 -432
- package/layers/ui/app/i18n/en.ts +0 -434
- package/layers/ui/app/utils/boolean-field.ts +0 -10
- package/layers/ui/app/utils/field-component.ts +0 -22
- package/layers/ui/app/utils/field-empty.ts +0 -21
- package/layers/ui/app/utils/field-registry.ts +0 -36
- package/layers/ui/app/utils/humanize.ts +0 -14
- package/layers/ui/app/utils/icons.ts +0 -143
- package/layers/ui/app/utils/iso-date.ts +0 -18
- package/layers/ui/app/utils/localized.ts +0 -13
- package/layers/ui/app/utils/record-context.ts +0 -15
- package/layers/ui/app/utils/reorder.ts +0 -8
- package/layers/ui/app/utils/richtext-preserve-class.ts +0 -31
- package/layers/ui/app/utils/useFieldA11y.ts +0 -40
- package/layers/ui/nuxt.config.ts +0 -9
- package/nuxt.config.ts +0 -108
- package/scripts/consumer-template-ci.mjs +0 -160
- package/scripts/copy-create-payload.mjs +0 -49
- package/scripts/coverage-all.mjs +0 -163
- package/scripts/dashboard.mjs +0 -89
- package/scripts/hash-password.mjs +0 -29
- package/scripts/kestrel.mjs +0 -240
- package/scripts/lib/cli.mjs +0 -129
- package/scripts/lib/coverage-model.mjs +0 -51
- package/scripts/lib/password.mjs +0 -19
- package/scripts/lib/scaffold.mjs +0 -205
- package/scripts/port-graph.mjs +0 -350
- package/scripts/typecheck.mjs +0 -62
- package/templates/starter/README.md +0 -65
- package/templates/starter/_env.example +0 -17
- package/templates/starter/_gitignore +0 -26
- package/templates/starter/_package.json +0 -22
- package/templates/starter/app/app.vue +0 -7
- package/templates/starter/app/blocks/Prose.vue +0 -13
- package/templates/starter/app/layouts/default.vue +0 -6
- package/templates/starter/nuxt.config.ts +0 -23
- package/templates/starter/pnpm-workspace.yaml +0 -8
- 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>
|