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