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