@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,39 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
import type { OpItem, RelocationReport } from '../utils/ops'
|
|
3
|
+
|
|
4
|
+
type Strategy = 'abort' | 'skip' | 'overwrite' | 'rename'
|
|
5
|
+
|
|
6
|
+
export function useMediaRelocate(onChanged?: () => void) {
|
|
7
|
+
const { t } = useT()
|
|
8
|
+
const busy = ref(false)
|
|
9
|
+
const error = ref<string | null>(null)
|
|
10
|
+
|
|
11
|
+
async function preview(type: 'move' | 'copy', items: OpItem[], dest: string): Promise<RelocationReport> {
|
|
12
|
+
error.value = null
|
|
13
|
+
try {
|
|
14
|
+
return await $fetch<RelocationReport>(`/api/media/${type}`, { method: 'POST', body: { items, dest, dryRun: true } })
|
|
15
|
+
} catch (e) {
|
|
16
|
+
error.value = (e as { statusMessage?: string })?.statusMessage ?? t('media.error.relocate')
|
|
17
|
+
throw e
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function execute(type: 'move' | 'copy', items: OpItem[], dest: string, onConflict: Strategy): Promise<void> {
|
|
22
|
+
busy.value = true
|
|
23
|
+
error.value = null
|
|
24
|
+
try {
|
|
25
|
+
await $fetch(`/api/media/${type}`, { method: 'POST', body: { items, dest, onConflict } })
|
|
26
|
+
onChanged?.()
|
|
27
|
+
} catch (e) {
|
|
28
|
+
const err = e as { statusCode?: number; statusMessage?: string }
|
|
29
|
+
error.value = err.statusCode === 409
|
|
30
|
+
? t('media.error.relocateConflict')
|
|
31
|
+
: err.statusMessage ?? t('media.error.relocate')
|
|
32
|
+
throw e
|
|
33
|
+
} finally {
|
|
34
|
+
busy.value = false
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return { busy, error, preview, execute }
|
|
39
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { reactive, toValue } from 'vue'
|
|
2
|
+
import type { MaybeRefOrGetter } from 'vue'
|
|
3
|
+
import type { ResolvedMedia } from '../../server/utils/resolve'
|
|
4
|
+
|
|
5
|
+
// The client-facing resolved-media shape (`GET /api/media/resolve`) is the server resolver's own
|
|
6
|
+
// return type — re-exported so consumers keep importing it from here, single-sourced so it can't drift.
|
|
7
|
+
export type { ResolvedMedia }
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Batched, cached client resolution of media ids → `ResolvedMedia`. A reactive cache so callers can
|
|
11
|
+
* `resolve(id)` inside a computed and have it re-evaluate once `ensure()` fills the cache. Per
|
|
12
|
+
* instance (no shared module state → SSR-safe). The cache is keyed per locale, and `locale` may be
|
|
13
|
+
* a ref/getter — a locale change makes its ids "missing" again so `ensure()` re-fetches and
|
|
14
|
+
* `resolve()` returns the new-locale entry (translations differ per locale).
|
|
15
|
+
*/
|
|
16
|
+
export function useMediaResolver(locale?: MaybeRefOrGetter<string>) {
|
|
17
|
+
const cache = reactive(new Map<string, ResolvedMedia>())
|
|
18
|
+
// Keys that resolved to NOTHING (deleted/dangling id). Without this, a missing id is "missing" forever and
|
|
19
|
+
// re-requested on every ensure(); the negative cache stops re-fetching it. Keyed per (locale, id) like the
|
|
20
|
+
// positive cache, so a locale change still re-resolves. Only populated on a SUCCESSFUL fetch that omitted
|
|
21
|
+
// the id — a network error leaves the id retryable.
|
|
22
|
+
const absent = new Set<string>()
|
|
23
|
+
const pending = new Set<string>()
|
|
24
|
+
const loc = () => toValue(locale) || 'en'
|
|
25
|
+
const keyFor = (l: string, id: number) => `${l}:${id}`
|
|
26
|
+
|
|
27
|
+
async function ensure(ids: number[]): Promise<void> {
|
|
28
|
+
const l = loc()
|
|
29
|
+
const missing = ids.filter((id) => Number.isInteger(id) && id > 0 && !cache.has(keyFor(l, id)) && !absent.has(keyFor(l, id)) && !pending.has(keyFor(l, id)))
|
|
30
|
+
if (!missing.length) return
|
|
31
|
+
for (const id of missing) pending.add(keyFor(l, id))
|
|
32
|
+
try {
|
|
33
|
+
const { data } = await $fetch<{ data: ResolvedMedia[] }>('/api/media/resolve', { query: { ids: missing.join(','), locale: l } })
|
|
34
|
+
const returned = new Set<number>()
|
|
35
|
+
for (const m of data) { cache.set(keyFor(l, m.id), m); returned.add(m.id) }
|
|
36
|
+
// A requested id the server didn't return is gone → remember it so we don't keep asking.
|
|
37
|
+
for (const id of missing) if (!returned.has(id)) absent.add(keyFor(l, id))
|
|
38
|
+
} catch {
|
|
39
|
+
// leave unresolved (NOT absent) — a transient error stays retryable on a later attempt
|
|
40
|
+
} finally {
|
|
41
|
+
for (const id of missing) pending.delete(keyFor(l, id))
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolve(id: number): ResolvedMedia | null {
|
|
46
|
+
return cache.get(keyFor(loc(), id)) ?? null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { cache, ensure, resolve }
|
|
50
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { ref, computed } from 'vue'
|
|
2
|
+
import type { PendingUpload } from '../utils/dnd'
|
|
3
|
+
|
|
4
|
+
export interface UploadItem {
|
|
5
|
+
id: string; file: File; filename: string; folder: string
|
|
6
|
+
status: 'queued' | 'uploading' | 'done' | 'error' | 'conflict' | 'skipped'
|
|
7
|
+
message?: string; suggestion?: string; existingId?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface UploadCallbacks {
|
|
11
|
+
/** Fired once a batch has fully settled (no active uploads, no unresolved conflicts). */
|
|
12
|
+
onSettled?: () => void
|
|
13
|
+
/** Fired for each item that fails (non-conflict). The component surfaces the reason (`item.message`). */
|
|
14
|
+
onError?: (item: UploadItem) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function useMediaUpload(cb: UploadCallbacks = {}) {
|
|
18
|
+
const queue = ref<UploadItem[]>([])
|
|
19
|
+
let seq = 0
|
|
20
|
+
const conflicts = computed(() => queue.value.filter((i) => i.status === 'conflict'))
|
|
21
|
+
const active = computed(() => queue.value.some((i) => i.status === 'queued' || i.status === 'uploading'))
|
|
22
|
+
const counts = computed(() => ({
|
|
23
|
+
total: queue.value.length,
|
|
24
|
+
done: queue.value.filter((i) => i.status === 'done').length,
|
|
25
|
+
error: queue.value.filter((i) => i.status === 'error').length,
|
|
26
|
+
conflict: conflicts.value.length,
|
|
27
|
+
skipped: queue.value.filter((i) => i.status === 'skipped').length,
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
async function uploadItem(item: UploadItem, opts: { overwrite?: boolean; filename?: string } = {}) {
|
|
31
|
+
item.status = 'uploading'
|
|
32
|
+
const fd = new FormData()
|
|
33
|
+
fd.append('file', item.file)
|
|
34
|
+
if (item.folder) fd.append('folder', item.folder)
|
|
35
|
+
fd.append('filename', opts.filename ?? item.filename)
|
|
36
|
+
if (opts.overwrite) fd.append('overwrite', 'true')
|
|
37
|
+
try {
|
|
38
|
+
await $fetch('/api/media', { method: 'POST', body: fd })
|
|
39
|
+
item.status = 'done'
|
|
40
|
+
} catch (e) {
|
|
41
|
+
type ConflictData = { suggestion?: string; existingId?: number }
|
|
42
|
+
const err = e as { statusCode?: number; statusMessage?: string; data?: ConflictData & { statusMessage?: string; data?: ConflictData } }
|
|
43
|
+
if (err.statusCode === 409) {
|
|
44
|
+
// $fetch wraps the thrown H3 error: the original payload is nested under data.data over HTTP.
|
|
45
|
+
const payload = err.data?.data ?? err.data
|
|
46
|
+
item.status = 'conflict'; item.suggestion = payload?.suggestion; item.existingId = payload?.existingId
|
|
47
|
+
} else if (err.statusCode === 401) {
|
|
48
|
+
// A mid-session 401 is owned by the re-auth interceptor (it redirects to login) — don't also
|
|
49
|
+
// raise a per-item toast for the same event.
|
|
50
|
+
item.status = 'error'
|
|
51
|
+
} else {
|
|
52
|
+
// Prefer the server reason from the JSON body (transport-independent) over `statusMessage`,
|
|
53
|
+
// which mirrors `response.statusText` — blank on HTTP/2 and rewritten by proxies. The component
|
|
54
|
+
// localizes a fallback when neither is present.
|
|
55
|
+
item.status = 'error'; item.message = err.data?.statusMessage ?? err.statusMessage
|
|
56
|
+
cb.onError?.(item)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function settle() { if (!active.value && !conflicts.value.length) cb.onSettled?.() }
|
|
62
|
+
|
|
63
|
+
async function run() {
|
|
64
|
+
for (const item of queue.value) if (item.status === 'queued') await uploadItem(item)
|
|
65
|
+
settle()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function enqueueUploads(uploads: PendingUpload[]) {
|
|
69
|
+
for (const u of uploads) queue.value.push({ id: `u${++seq}`, file: u.file, filename: u.file.name, folder: u.folder, status: 'queued' })
|
|
70
|
+
return run()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function enqueue(files: File[], folder: string) {
|
|
74
|
+
return enqueueUploads(files.map((file) => ({ file, folder })))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function resolve(id: string, action: 'overwrite' | 'rename' | 'skip', name?: string) {
|
|
78
|
+
const item = queue.value.find((i) => i.id === id)
|
|
79
|
+
if (!item || item.status !== 'conflict') return
|
|
80
|
+
if (action === 'skip') item.status = 'skipped'
|
|
81
|
+
else if (action === 'overwrite') await uploadItem(item, { overwrite: true })
|
|
82
|
+
else { item.filename = name ?? item.suggestion ?? item.filename; await uploadItem(item, { filename: item.filename }) }
|
|
83
|
+
settle()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function resolveAll(action: 'overwrite' | 'rename' | 'skip') {
|
|
87
|
+
for (const item of [...conflicts.value]) await resolve(item.id, action, action === 'rename' ? item.suggestion : undefined)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function reset() { queue.value = [] }
|
|
91
|
+
|
|
92
|
+
return { queue, conflicts, active, counts, enqueue, enqueueUploads, resolve, resolveAll, reset }
|
|
93
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
|
|
2
|
+
import type { ResolvedMedia } from '../../server/utils/resolve'
|
|
3
|
+
import type { ResolvedVariant } from '../../../core/server/utils/kestrel-config'
|
|
4
|
+
import { buildPictureModel, requestVariantSpecs, type VariantRequest, type PictureModel } from '../utils/picture-model'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reactive `<picture>` view-model for a resolved media + a variant request. The single render-AND-declare
|
|
8
|
+
* site: one call may declare MULTIPLE sizes + formats. During a publish/generate SSR render it also stashes
|
|
9
|
+
* the call's concrete specs on the request event, so the publish `beforeResponse` hook collects them and the
|
|
10
|
+
* end-of-publish reconcile registers exactly what was rendered (usage-driven auto-discovery). Server-guarded,
|
|
11
|
+
* so it is a no-op on the client and in non-Nuxt unit mounts.
|
|
12
|
+
*/
|
|
13
|
+
export function useMediaVariant(
|
|
14
|
+
media: MaybeRefOrGetter<ResolvedMedia | null | undefined>,
|
|
15
|
+
request: MaybeRefOrGetter<VariantRequest>,
|
|
16
|
+
opts?: { priority?: MaybeRefOrGetter<boolean> },
|
|
17
|
+
) {
|
|
18
|
+
if (import.meta.server) {
|
|
19
|
+
const specs = requestVariantSpecs(toValue(request))
|
|
20
|
+
if (specs.length) {
|
|
21
|
+
const ev = useRequestEvent()
|
|
22
|
+
if (ev) {
|
|
23
|
+
const ctx = ev.context as { kestrelVariants?: ResolvedVariant[] }
|
|
24
|
+
;(ctx.kestrelVariants ??= []).push(...specs)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const model = computed<PictureModel | null>(() => {
|
|
29
|
+
const m = toValue(media)
|
|
30
|
+
if (!m) return null
|
|
31
|
+
return buildPictureModel(m, toValue(request), { lazy: !toValue(opts?.priority ?? false) })
|
|
32
|
+
})
|
|
33
|
+
return { model }
|
|
34
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { joinFolder } from './library'
|
|
2
|
+
|
|
3
|
+
export interface PendingUpload { file: File; folder: string }
|
|
4
|
+
export interface WalkResult { uploads: PendingUpload[]; dirs: string[] }
|
|
5
|
+
|
|
6
|
+
export interface DirectoryReaderLike {
|
|
7
|
+
readEntries(success: (entries: EntryLike[]) => void, error?: (e: unknown) => void): void
|
|
8
|
+
}
|
|
9
|
+
export interface EntryLike {
|
|
10
|
+
isFile: boolean
|
|
11
|
+
isDirectory: boolean
|
|
12
|
+
name: string
|
|
13
|
+
file?(success: (file: File) => void, error?: (e: unknown) => void): void
|
|
14
|
+
createReader?(): DirectoryReaderLike
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Drain a DirectoryReader — readEntries yields in batches (Chrome caps ~100), loop until empty. */
|
|
18
|
+
export function readAllEntries(reader: DirectoryReaderLike): Promise<EntryLike[]> {
|
|
19
|
+
return new Promise((resolve, reject) => {
|
|
20
|
+
const all: EntryLike[] = []
|
|
21
|
+
const next = () => reader.readEntries((batch) => { if (!batch.length) return resolve(all); all.push(...batch); next() }, reject)
|
|
22
|
+
next()
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function entryFile(entry: EntryLike): Promise<File> {
|
|
27
|
+
return new Promise((resolve, reject) => entry.file!((f) => resolve(f), reject))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function walkEntry(entry: EntryLike, base: string, out: WalkResult): Promise<void> {
|
|
31
|
+
if (entry.isFile) {
|
|
32
|
+
out.uploads.push({ file: await entryFile(entry), folder: base })
|
|
33
|
+
} else if (entry.isDirectory && entry.createReader) {
|
|
34
|
+
const dir = joinFolder(base, entry.name)
|
|
35
|
+
out.dirs.push(dir)
|
|
36
|
+
for (const child of await readAllEntries(entry.createReader())) await walkEntry(child, dir, out)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface DataTransferLike {
|
|
41
|
+
items?: ArrayLike<{ webkitGetAsEntry?(): EntryLike | null }> | null
|
|
42
|
+
files?: ArrayLike<File> | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function extractUploads(dt: DataTransferLike, base: string): Promise<WalkResult> {
|
|
46
|
+
const out: WalkResult = { uploads: [], dirs: [] }
|
|
47
|
+
// Grab entries SYNCHRONOUSLY before any await — the DataTransferItemList dies once the handler yields.
|
|
48
|
+
const entries = (dt.items ? Array.from(dt.items) : [])
|
|
49
|
+
.map((it) => it.webkitGetAsEntry?.() ?? null)
|
|
50
|
+
.filter((e): e is EntryLike => !!e)
|
|
51
|
+
if (entries.length) {
|
|
52
|
+
for (const entry of entries) await walkEntry(entry, base, out)
|
|
53
|
+
} else {
|
|
54
|
+
for (const file of Array.from(dt.files ?? [])) out.uploads.push({ file, folder: base })
|
|
55
|
+
}
|
|
56
|
+
return out
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The minimal set of folders to create explicitly: dirs no uploaded file already creates
|
|
60
|
+
* (ensure-on-upload makes a file's whole ancestor chain), reduced to the deepest. */
|
|
61
|
+
export function selectEmptyFolders(uploads: PendingUpload[], dirs: string[]): string[] {
|
|
62
|
+
const covered = new Set<string>()
|
|
63
|
+
for (const u of uploads) {
|
|
64
|
+
const segs = u.folder.split('/').filter(Boolean)
|
|
65
|
+
for (let i = 1; i <= segs.length; i++) covered.add(segs.slice(0, i).join('/'))
|
|
66
|
+
}
|
|
67
|
+
const empty = dirs.filter((d) => d && !covered.has(d))
|
|
68
|
+
return empty.filter((d) => !empty.some((o) => o !== d && o.startsWith(`${d}/`)))
|
|
69
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { LibraryFolder } from '../../server/utils/library'
|
|
2
|
+
|
|
3
|
+
// Re-exported from the server util that produces it (`GET /api/media/library`) so the wire shape is
|
|
4
|
+
// single-sourced.
|
|
5
|
+
export type { LibraryFolder }
|
|
6
|
+
|
|
7
|
+
export interface LibraryFile {
|
|
8
|
+
id: number; filename: string; mime: string; folder: string; size: number
|
|
9
|
+
width?: number; height?: number; thumbhash?: string; src: string; srcset?: string; alt?: string
|
|
10
|
+
createdAt?: string
|
|
11
|
+
}
|
|
12
|
+
export type LibraryItem =
|
|
13
|
+
| { type: 'folder'; folder: LibraryFolder }
|
|
14
|
+
| { type: 'file'; file: LibraryFile }
|
|
15
|
+
|
|
16
|
+
export function itemKey(item: LibraryItem): string {
|
|
17
|
+
return item.type === 'folder' ? `folder:${item.folder.path}` : `file:${item.file.id}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Inclusive range between the anchor and target keys (by position in `orderedKeys`), unioned
|
|
21
|
+
* onto `current`. If the anchor is not found, selects only the target. */
|
|
22
|
+
export function computeRange(orderedKeys: string[], anchorKey: string, targetKey: string, current: Set<string>): Set<string> {
|
|
23
|
+
const out = new Set(current)
|
|
24
|
+
const t = orderedKeys.indexOf(targetKey)
|
|
25
|
+
if (t < 0) return out
|
|
26
|
+
const a = orderedKeys.indexOf(anchorKey)
|
|
27
|
+
if (a < 0) { out.add(targetKey); return out }
|
|
28
|
+
for (let i = Math.min(a, t); i <= Math.max(a, t); i++) out.add(orderedKeys[i])
|
|
29
|
+
return out
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function humanizeSize(bytes: number): string {
|
|
33
|
+
if (bytes < 1024) return `${bytes} B`
|
|
34
|
+
const kb = bytes / 1024
|
|
35
|
+
if (kb < 1024) return `${kb.toFixed(1)} KB`
|
|
36
|
+
return `${(kb / 1024).toFixed(1)} MB`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The parent of an internal folder path, or `null` at the root — never above it (no path traversal).
|
|
40
|
+
* `''` → null (root); `'a'` → `''`; `'a/b'` → `'a'`. */
|
|
41
|
+
export function parentFolder(folder: string): string | null {
|
|
42
|
+
if (folder === '') return null
|
|
43
|
+
const i = folder.lastIndexOf('/')
|
|
44
|
+
return i === -1 ? '' : folder.slice(0, i)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Split a typed folder path into the existing-parent part and the trailing fragment being typed. */
|
|
48
|
+
export function splitPathInput(input: string): { parent: string; fragment: string } {
|
|
49
|
+
const i = input.lastIndexOf('/')
|
|
50
|
+
return i === -1 ? { parent: '', fragment: input } : { parent: input.slice(0, i), fragment: input.slice(i + 1) }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Join folder parts into a clean relative path (drops empty segments). Also parses a typed display
|
|
54
|
+
* path back to the internal form: `joinFolder('/test123/')` → `'test123'`, `joinFolder('/')` → `''`. */
|
|
55
|
+
export function joinFolder(...parts: string[]): string {
|
|
56
|
+
return parts.flatMap((p) => p.split('/')).filter(Boolean).join('/')
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Internal folder path → the slash-delimited path shown to the user, rooted at the media directory:
|
|
60
|
+
* `''` → `'/'`, `'test123'` → `'/test123/'`, `'a/b'` → `'/a/b/'`. Tolerant of stray slashes in input. */
|
|
61
|
+
export function displayFolderPath(folder: string): string {
|
|
62
|
+
const clean = joinFolder(folder)
|
|
63
|
+
return clean === '' ? '/' : `/${clean}/`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Longest common FOLDER of clean relative paths; '' (root) when they diverge at root or none given.
|
|
67
|
+
* Keeps only equal leading segments and drops empties, so it can never emit '..' or escape root. */
|
|
68
|
+
export function commonFolder(folders: string[]): string {
|
|
69
|
+
if (!folders.length) return ''
|
|
70
|
+
const split = folders.map((f) => f.split('/').filter(Boolean))
|
|
71
|
+
let prefix = split[0]!
|
|
72
|
+
for (const segs of split.slice(1)) {
|
|
73
|
+
let i = 0
|
|
74
|
+
while (i < prefix.length && prefix[i] === segs[i]) i++
|
|
75
|
+
prefix = prefix.slice(0, i)
|
|
76
|
+
if (!prefix.length) break
|
|
77
|
+
}
|
|
78
|
+
return prefix.join('/')
|
|
79
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { LibraryItem } from './library'
|
|
2
|
+
import type { OpItem, DeleteReport } from '../../server/utils/media-ops'
|
|
3
|
+
import type { Conflict, RelocationReport } from '../../server/utils/relocate-ops'
|
|
4
|
+
import type { MediaUsage } from '../../server/utils/usages'
|
|
5
|
+
|
|
6
|
+
// Single-source the HTTP wire shapes from the server utils that own them — one definition, no drift.
|
|
7
|
+
export type { OpItem, DeleteReport, Conflict, RelocationReport, MediaUsage }
|
|
8
|
+
|
|
9
|
+
/** Structural shape of a UiMenu entry (UiMenu declares its own copy; kept in sync structurally). */
|
|
10
|
+
export interface MenuItem { label: string; value: string; danger?: boolean; disabled?: boolean }
|
|
11
|
+
|
|
12
|
+
/** A menu entry before its label is localized: carries an i18n key (+ a `count` for plural-bearing
|
|
13
|
+
* entries) so the pure builder stays free of presentation copy; the composable resolves it via useT. */
|
|
14
|
+
export interface MenuItemSpec { labelKey: string; count?: number; value: string; danger?: boolean }
|
|
15
|
+
|
|
16
|
+
export function toOpItem(item: LibraryItem): OpItem {
|
|
17
|
+
return item.type === 'folder' ? { type: 'folder', path: item.folder.path } : { type: 'file', id: item.file.id }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Map a right-clicked element to its LibraryItem via the data-id attributes
|
|
21
|
+
* (files carry data-file-id, folders carry data-drop-folder). Null if neither matches. */
|
|
22
|
+
export function resolveTargetItem(el: Element | null, items: LibraryItem[]): LibraryItem | null {
|
|
23
|
+
const fileEl = el?.closest('[data-file-id]')
|
|
24
|
+
if (fileEl) {
|
|
25
|
+
const id = Number(fileEl.getAttribute('data-file-id'))
|
|
26
|
+
return items.find((i) => i.type === 'file' && i.file.id === id) ?? null
|
|
27
|
+
}
|
|
28
|
+
const folderEl = el?.closest('[data-drop-folder]')
|
|
29
|
+
if (folderEl) {
|
|
30
|
+
const path = folderEl.getAttribute('data-drop-folder')
|
|
31
|
+
return items.find((i) => i.type === 'folder' && i.folder.path === path) ?? null
|
|
32
|
+
}
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Explorer semantics: right-clicking a selected item targets the whole selection;
|
|
37
|
+
* right-clicking elsewhere targets just that item. */
|
|
38
|
+
export function effectiveTargets(clicked: LibraryItem, isSelected: (i: LibraryItem) => boolean, selected: LibraryItem[]): LibraryItem[] {
|
|
39
|
+
return isSelected(clicked) ? selected : [clicked]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Build the context-menu items for the current target + clipboard state. Blank space with an
|
|
43
|
+
* empty clipboard returns [] (the caller suppresses the menu). */
|
|
44
|
+
export function buildMenuItems(ctx: { targetCount: number; targetType: 'file' | 'folder' | null; clipboardEmpty: boolean }): MenuItemSpec[] {
|
|
45
|
+
const { targetCount, targetType, clipboardEmpty } = ctx
|
|
46
|
+
if (targetCount === 0) return clipboardEmpty ? [] : [{ labelKey: 'media.menu.paste', value: 'paste' }]
|
|
47
|
+
if (targetCount === 1) {
|
|
48
|
+
const items: MenuItemSpec[] = [
|
|
49
|
+
{ labelKey: 'media.menu.cut', value: 'cut' },
|
|
50
|
+
{ labelKey: 'media.menu.copy', value: 'copy' },
|
|
51
|
+
{ labelKey: 'media.menu.rename', value: 'rename' },
|
|
52
|
+
{ labelKey: 'media.menu.delete', value: 'delete', danger: true },
|
|
53
|
+
]
|
|
54
|
+
if (targetType === 'folder' && !clipboardEmpty) items.push({ labelKey: 'media.menu.pasteInto', value: 'paste' })
|
|
55
|
+
return items
|
|
56
|
+
}
|
|
57
|
+
return [
|
|
58
|
+
{ labelKey: 'media.menu.cut', value: 'cut' },
|
|
59
|
+
{ labelKey: 'media.menu.copy', value: 'copy' },
|
|
60
|
+
{ labelKey: 'media.menu.deleteN', count: targetCount, value: 'delete', danger: true },
|
|
61
|
+
]
|
|
62
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { ResolvedMedia } from '../../server/utils/resolve'
|
|
2
|
+
import type { VariantFit, VariantFormat, ResolvedVariant } from '../../../core/server/utils/kestrel-config'
|
|
3
|
+
import { variantName } from './variant-name'
|
|
4
|
+
|
|
5
|
+
/** What a `KestrelImg` / `useMediaVariant` call declares it needs. One call may declare MULTIPLE sizes and
|
|
6
|
+
* formats: `widths` (proportional responsive set) and/or a fixed-box `crop`, plus named config `preset`s,
|
|
7
|
+
* crossed with `formats` (preference order, most-modern first). */
|
|
8
|
+
export interface VariantRequest {
|
|
9
|
+
widths?: number[]
|
|
10
|
+
crop?: { width: number; height: number; fit?: VariantFit }
|
|
11
|
+
preset?: string | string[]
|
|
12
|
+
formats?: VariantFormat[]
|
|
13
|
+
/** The `<img sizes>` attribute; `auto` (or `auto, <fallback>`) is honoured on lazy images (see resolveSizes). */
|
|
14
|
+
sizes?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PictureSource { type: string; srcset: string }
|
|
18
|
+
export interface PictureModel {
|
|
19
|
+
sources: PictureSource[]
|
|
20
|
+
src: string
|
|
21
|
+
width: number | null
|
|
22
|
+
height: number | null
|
|
23
|
+
alt: string
|
|
24
|
+
sizes?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MIME: Record<string, string> = { webp: 'image/webp', jpeg: 'image/jpeg', jpg: 'image/jpeg', png: 'image/png', avif: 'image/avif', gif: 'image/gif' }
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `sizes=auto` lets the browser pick the srcset candidate from the image's rendered width, but ONLY on
|
|
31
|
+
* lazy-loaded images — on eager ones it is inert. Non-supporting browsers ignore the `auto` keyword and use
|
|
32
|
+
* the fallback after it, so `auto, 100vw` degrades to `100vw`. Hence: keep `auto` only when lazy (with any
|
|
33
|
+
* fallback), and on an eager image drop the keyword, leaving just the fallback.
|
|
34
|
+
*/
|
|
35
|
+
export function resolveSizes(sizes: string | undefined, lazy: boolean): string | undefined {
|
|
36
|
+
if (!sizes) return undefined
|
|
37
|
+
const t = sizes.trim()
|
|
38
|
+
if (/^auto\b/i.test(t)) {
|
|
39
|
+
const fallback = t.slice(4).replace(/^\s*,\s*/, '').trim()
|
|
40
|
+
if (!lazy) return fallback || undefined
|
|
41
|
+
return fallback ? `auto, ${fallback}` : 'auto'
|
|
42
|
+
}
|
|
43
|
+
return t
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The concrete, registrable specs a request declares (for the prerender scan): each `width` → a
|
|
48
|
+
* proportional `w<width>` spec, a `crop` → a fixed-box spec, both crossed with the request's formats.
|
|
49
|
+
* Named `preset`s are NOT expanded — they are already config-resolved (referenced by name, not discovered).
|
|
50
|
+
*/
|
|
51
|
+
export function requestVariantSpecs(req: VariantRequest): ResolvedVariant[] {
|
|
52
|
+
const formats: VariantFormat[] = req.formats?.length ? req.formats : ['webp']
|
|
53
|
+
const out: ResolvedVariant[] = []
|
|
54
|
+
// props may bind strings; drop any non-positive-integer dim so a bad spec is never registered (would crash sharp later)
|
|
55
|
+
for (const raw of req.widths ?? []) {
|
|
56
|
+
const w = posInt(raw)
|
|
57
|
+
if (w != null) out.push({ name: variantName({ width: w }), width: w, height: null, fit: 'cover', position: 'centre', formats: [...formats] })
|
|
58
|
+
}
|
|
59
|
+
if (req.crop) {
|
|
60
|
+
const w = posInt(req.crop.width)
|
|
61
|
+
const h = posInt(req.crop.height)
|
|
62
|
+
if (w != null && h != null) out.push({ name: variantName({ width: w, height: h, fit: req.crop.fit }), width: w, height: h, fit: req.crop.fit ?? 'cover', position: 'centre', formats: [...formats] })
|
|
63
|
+
}
|
|
64
|
+
return out
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function posInt(value: unknown): number | null {
|
|
68
|
+
const n = Math.floor(Number(value))
|
|
69
|
+
return Number.isFinite(n) && n >= 1 ? n : null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The variant names a request targets: derived names for widths/crop + any explicit preset names.
|
|
73
|
+
* Applies the SAME posInt sanitisation as requestVariantSpecs so the render-lookup name matches the
|
|
74
|
+
* registered/derived name — otherwise a fractional width registers `w640` but looks up `w640.5`, and the
|
|
75
|
+
* derivative is never used. */
|
|
76
|
+
export function requestedNames(req: VariantRequest): string[] {
|
|
77
|
+
const names: string[] = []
|
|
78
|
+
for (const raw of req.widths ?? []) {
|
|
79
|
+
const w = posInt(raw)
|
|
80
|
+
if (w != null) names.push(variantName({ width: w }))
|
|
81
|
+
}
|
|
82
|
+
if (req.crop) {
|
|
83
|
+
const w = posInt(req.crop.width)
|
|
84
|
+
const h = posInt(req.crop.height)
|
|
85
|
+
if (w != null && h != null) names.push(variantName({ width: w, height: h, fit: req.crop.fit }))
|
|
86
|
+
}
|
|
87
|
+
const presets = req.preset ? (Array.isArray(req.preset) ? req.preset : [req.preset]) : []
|
|
88
|
+
names.push(...presets)
|
|
89
|
+
return [...new Set(names)]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build the `<picture>` view-model for a resolved media + a variant request: one `<source>` per requested
|
|
94
|
+
* format (in the request's preference order) whose srcset is the matching derivatives, and an `<img>`
|
|
95
|
+
* fallback = the largest derivative of the LAST (most-compatible) format, or the original when none exist.
|
|
96
|
+
* Pure — the load-bearing render logic, unit-tested without a DOM.
|
|
97
|
+
*/
|
|
98
|
+
export function buildPictureModel(media: ResolvedMedia, req: VariantRequest, opts: { lazy: boolean }): PictureModel {
|
|
99
|
+
const names = new Set(requestedNames(req))
|
|
100
|
+
const formats = (req.formats?.length ? req.formats : ['webp']) as string[]
|
|
101
|
+
const pool = media.variants.filter((v) => names.has(v.name))
|
|
102
|
+
|
|
103
|
+
const sources: PictureSource[] = []
|
|
104
|
+
for (const fmt of formats) {
|
|
105
|
+
const entries = pool.filter((v) => v.format === fmt).sort((a, b) => a.width - b.width)
|
|
106
|
+
if (!entries.length) continue
|
|
107
|
+
sources.push({ type: MIME[fmt] ?? `image/${fmt}`, srcset: entries.map((v) => `${v.url} ${v.width}w`).join(', ') })
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// <img> fallback: the largest derivative of the last (most-compatible) requested format; else the original
|
|
111
|
+
// (which may be a large PNG/AVIF, but is always a valid last resort).
|
|
112
|
+
const lastFmt = formats[formats.length - 1]
|
|
113
|
+
const lastEntries = pool.filter((v) => v.format === lastFmt).sort((a, b) => a.width - b.width)
|
|
114
|
+
const fallback = lastEntries[lastEntries.length - 1]
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
sources,
|
|
118
|
+
src: fallback?.url ?? media.src,
|
|
119
|
+
width: fallback?.width ?? media.width,
|
|
120
|
+
height: fallback?.height ?? media.height,
|
|
121
|
+
alt: media.alt ?? '',
|
|
122
|
+
// Lazy images with no explicit sizes default to `auto, 100vw` — a strict upgrade over the 100vw default.
|
|
123
|
+
sizes: resolveSizes(req.sizes ?? (opts.lazy ? 'auto, 100vw' : undefined), opts.lazy),
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { thumbHashToDataURL } from 'thumbhash'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Decode a base64 thumbhash (as stored by the media pipeline, `derive.ts`) to a tiny PNG data URL — or
|
|
5
|
+
* null when absent/malformed. Pure + deterministic. Decoded CLIENT-SIDE only (see `useBlurUp`): the
|
|
6
|
+
* ~6 KB PNG data URL must not be inlined in SSR HTML — the ~30-byte thumbhash string ships in the
|
|
7
|
+
* payload and is inflated here after mount. Never throws — bad input degrades to null (no placeholder).
|
|
8
|
+
*/
|
|
9
|
+
export function thumbhashToDataUrl(thumbhash: string | null | undefined): string | null {
|
|
10
|
+
if (!thumbhash) return null
|
|
11
|
+
try {
|
|
12
|
+
const bin = atob(thumbhash)
|
|
13
|
+
const bytes = new Uint8Array(bin.length)
|
|
14
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
|
|
15
|
+
return thumbHashToDataURL(bytes)
|
|
16
|
+
} catch {
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { VariantFit } from '../../../core/server/utils/kestrel-config'
|
|
2
|
+
|
|
3
|
+
export interface VariantShape {
|
|
4
|
+
width: number
|
|
5
|
+
/** A fixed-box height (crop). null/undefined ⇒ proportional (width-only). */
|
|
6
|
+
height?: number | null
|
|
7
|
+
fit?: VariantFit
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Canonical stable name for a variant shape — the manifest/object-key stem. This ONE function is shared by
|
|
12
|
+
* the render lookup (`KestrelImg` / `useMediaVariant`), the prerender scan that registers a discovered spec,
|
|
13
|
+
* and (via the registry) `deriveImage`, so all three agree on the name. Names stay `[A-Za-z0-9_-]` so the
|
|
14
|
+
* `<name>.<format>` manifest key and the relocate parser split cleanly on the last dot.
|
|
15
|
+
*/
|
|
16
|
+
export function variantName(shape: VariantShape): string {
|
|
17
|
+
if (shape.height == null) return `w${shape.width}`
|
|
18
|
+
const fit = shape.fit ?? 'cover'
|
|
19
|
+
return fit === 'cover' ? `c${shape.width}x${shape.height}` : `c${shape.width}x${shape.height}-${fit}`
|
|
20
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { findMediaUsages } from '../../../utils/usages'
|
|
2
|
+
import { requireMediaCollection } from '../../../utils/media-enabled'
|
|
3
|
+
|
|
4
|
+
export default defineEventHandler((event) => {
|
|
5
|
+
requireMediaCollection()
|
|
6
|
+
const id = requireId(event)
|
|
7
|
+
return { id, usages: findMediaUsages(useDb(), id) }
|
|
8
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { eq, getTableColumns } from 'drizzle-orm'
|
|
2
|
+
import { media } from '../../collections/media'
|
|
3
|
+
import { useStorageDriver } from '../../../../core/server/utils/storage'
|
|
4
|
+
import { deleteAffected } from '../../utils/media-ops'
|
|
5
|
+
import { requireMediaCollection } from '../../utils/media-enabled'
|
|
6
|
+
|
|
7
|
+
export default defineEventHandler(async (event) => {
|
|
8
|
+
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
9
|
+
requireMediaCollection()
|
|
10
|
+
const id = requireId(event)
|
|
11
|
+
const cols = getTableColumns(media) as Record<string, never>
|
|
12
|
+
const db = useDb()
|
|
13
|
+
// deleteAffected silently no-ops on a missing row, so keep an explicit 404 guard.
|
|
14
|
+
const row = db.select({ id: cols.id }).from(media).where(eq(cols.id, id)).get() as { id: number } | undefined
|
|
15
|
+
if (!row) throw createError({ statusCode: 404, statusMessage: `media ${id} not found` })
|
|
16
|
+
await deleteAffected(db, useStorageDriver(), [{ type: 'file', id }], false)
|
|
17
|
+
return { deleted: true, id }
|
|
18
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import media from '../../collections/media'
|
|
2
|
+
import { requireMediaCollection } from '../../utils/media-enabled'
|
|
3
|
+
|
|
4
|
+
export default defineEventHandler((event) => {
|
|
5
|
+
requireMediaCollection()
|
|
6
|
+
const query = getQuery(event)
|
|
7
|
+
const depth = Number(query.depth ?? 0)
|
|
8
|
+
const locale = query.locale as string | undefined
|
|
9
|
+
return getOne(useDb(), media, requireId(event), depth, locale)
|
|
10
|
+
})
|