@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,25 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { FieldDef } from '../../../core/server/utils/defineCollection'
|
|
3
|
+
import { evaluateCondition, isEmptyValue } from '../../app/utils/condition'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Re-enforce `required` for conditional sub-fields whose condition is met, at the object level where their
|
|
7
|
+
* sibling values are visible (a block's props, a repeater entry). The per-field validator relaxed a
|
|
8
|
+
* `required + condition` field to optional (it can't see siblings), and the collection-level applyConditions
|
|
9
|
+
* hook only walks TOP-LEVEL fields — so without this, a conditional-required prop inside a block or repeater
|
|
10
|
+
* is a silent server no-op. Adds a superRefine that checks each such field against the parsed object; a no-op
|
|
11
|
+
* (returns the schema unchanged) when the shape has no conditional-required fields.
|
|
12
|
+
*/
|
|
13
|
+
export function refineConditionalRequired<T extends z.ZodTypeAny>(schema: T, fields: Record<string, FieldDef>): z.ZodTypeAny {
|
|
14
|
+
const conditional = Object.entries(fields).filter(([, f]) => f.condition && f.required)
|
|
15
|
+
if (!conditional.length) return schema
|
|
16
|
+
return schema.superRefine((val, ctx) => {
|
|
17
|
+
if (!val || typeof val !== 'object') return
|
|
18
|
+
const scope = val as Record<string, unknown>
|
|
19
|
+
for (const [key, field] of conditional) {
|
|
20
|
+
if (evaluateCondition(field.condition!, scope) && isEmptyValue(scope[key])) {
|
|
21
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, path: [key], message: 'This field is required.' })
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { ZodObject } from 'zod'
|
|
3
|
+
import type { FieldDef, Localized } from '../../../core/server/utils/defineCollection'
|
|
4
|
+
import { buildFieldObjectSchema } from './buildFieldSchema'
|
|
5
|
+
import { refineConditionalRequired } from './conditional-required'
|
|
6
|
+
|
|
7
|
+
export interface BlockDef {
|
|
8
|
+
name: string
|
|
9
|
+
fields: Record<string, FieldDef>
|
|
10
|
+
slots?: string[]
|
|
11
|
+
label?: Localized
|
|
12
|
+
/** Optional icon name for the admin block picker (e.g. a lucide name). Set via the SFC's `defineBlock`. */
|
|
13
|
+
icon?: string
|
|
14
|
+
/** Optional preview image (URL/path, e.g. '/block-previews/hero.png') shown above the name in the picker. */
|
|
15
|
+
image?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const blocks = new Map<string, BlockDef>()
|
|
19
|
+
|
|
20
|
+
export function defineBlock(def: BlockDef): BlockDef {
|
|
21
|
+
return def
|
|
22
|
+
}
|
|
23
|
+
export function registerBlock(def: BlockDef): void {
|
|
24
|
+
blocks.set(def.name, def)
|
|
25
|
+
}
|
|
26
|
+
export function getBlock(name: string): BlockDef | undefined {
|
|
27
|
+
return blocks.get(name)
|
|
28
|
+
}
|
|
29
|
+
export function clearBlocks(): void {
|
|
30
|
+
blocks.clear()
|
|
31
|
+
}
|
|
32
|
+
export function allBlocks(allowed?: string[]): BlockDef[] {
|
|
33
|
+
const all = [...blocks.values()]
|
|
34
|
+
return allowed?.length ? all.filter((b) => allowed.includes(b.name)) : all
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const MAX_BLOCK_DEPTH = 60
|
|
38
|
+
const MAX_BLOCK_NODES = 10_000
|
|
39
|
+
|
|
40
|
+
// Iterative (no recursion) traversal so the guard itself can't stack-overflow.
|
|
41
|
+
function withinBlockBounds(value: unknown): boolean {
|
|
42
|
+
const stack: Array<{ v: unknown; depth: number }> = [{ v: value, depth: 0 }]
|
|
43
|
+
let nodes = 0
|
|
44
|
+
while (stack.length) {
|
|
45
|
+
const top = stack.pop() as { v: unknown; depth: number }
|
|
46
|
+
const { v, depth } = top
|
|
47
|
+
if (v === null || typeof v !== 'object') continue
|
|
48
|
+
if (depth > MAX_BLOCK_DEPTH) return false
|
|
49
|
+
if (++nodes > MAX_BLOCK_NODES) return false
|
|
50
|
+
if (Array.isArray(v)) {
|
|
51
|
+
for (const item of v) stack.push({ v: item, depth: depth + 1 })
|
|
52
|
+
} else {
|
|
53
|
+
for (const key of Object.keys(v as Record<string, unknown>)) {
|
|
54
|
+
stack.push({ v: (v as Record<string, unknown>)[key], depth: depth + 1 })
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function buildBlocksSchema(blockDefs: BlockDef[]): z.ZodType {
|
|
62
|
+
const node: z.ZodType = blockDefs.length
|
|
63
|
+
? z.lazy(() =>
|
|
64
|
+
z.discriminatedUnion(
|
|
65
|
+
'type',
|
|
66
|
+
blockDefs.map((b) =>
|
|
67
|
+
z.object({
|
|
68
|
+
id: z.string().min(1),
|
|
69
|
+
type: z.literal(b.name),
|
|
70
|
+
props: refineConditionalRequired(z.object(buildFieldObjectSchema(b.fields)), b.fields),
|
|
71
|
+
slots: b.slots?.length
|
|
72
|
+
? z.object(Object.fromEntries(b.slots.map((s) => [s, z.array(node)]))).partial().optional()
|
|
73
|
+
: z.undefined().optional(),
|
|
74
|
+
}),
|
|
75
|
+
) as unknown as [ZodObject, ...ZodObject[]],
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
: z.never()
|
|
79
|
+
return z.unknown()
|
|
80
|
+
.superRefine((val, ctx) => {
|
|
81
|
+
if (!withinBlockBounds(val)) {
|
|
82
|
+
ctx.addIssue({ code: 'custom', message: 'Block tree exceeds the maximum depth or size' })
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
.pipe(z.array(node))
|
|
86
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { FieldTypeDescriptor } from '../field-registry/types'
|
|
2
|
+
import { registerFieldType } from '../field-registry'
|
|
3
|
+
|
|
4
|
+
export type { FieldTypeDescriptor }
|
|
5
|
+
// Re-export the descriptor helpers so a consumer's field-type file can build the Drizzle column + Zod with
|
|
6
|
+
// the SAME optionality/constraint logic the built-ins use — all auto-imported in a `server/field-types/` file.
|
|
7
|
+
export { constrain, opt, optArr, isHardRequired, getFieldType, registerFieldType } from '../field-registry'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A consumer-defined field type: a `name` plus the server contract — a Drizzle `column` and a Zod
|
|
11
|
+
* `validator`. Drop a file in `server/field-types/` that default-exports `defineFieldType({...})`; it is
|
|
12
|
+
* auto-discovered and registered BEFORE any table is built. Pair it with a client editor widget via
|
|
13
|
+
* `registerFieldComponent('<name>', Component)` in an `app/plugins/*.client.ts`.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* // server/field-types/color.ts
|
|
17
|
+
* import { text } from 'drizzle-orm/sqlite-core'
|
|
18
|
+
* import { z } from 'zod'
|
|
19
|
+
* export default defineFieldType({
|
|
20
|
+
* name: 'color',
|
|
21
|
+
* column: (n, f) => constrain(text(n), f),
|
|
22
|
+
* validator: (f) => opt(z.string().regex(/^#[0-9a-f]{6}$/i), f),
|
|
23
|
+
* })
|
|
24
|
+
*/
|
|
25
|
+
export interface FieldTypeDef extends FieldTypeDescriptor {
|
|
26
|
+
name: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function defineFieldType(def: FieldTypeDef): FieldTypeDescriptor {
|
|
30
|
+
const { name, ...descriptor } = def // keep column, validator AND transform — don't drop a consumer's transform
|
|
31
|
+
registerFieldType(name, descriptor)
|
|
32
|
+
return descriptor
|
|
33
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { CollectionDef, FieldDef } from '../../../core/server/utils/defineCollection'
|
|
2
|
+
import { fieldIs } from '../../../core/server/utils/defineCollection'
|
|
3
|
+
import type { KeyMode } from '../../../core/server/utils/populate'
|
|
4
|
+
import { seoPopulateFields } from '../../../core/server/utils/seo'
|
|
5
|
+
import { getBlock } from './defineBlock'
|
|
6
|
+
import { collectRichtextRefs } from '../../app/utils/richtext-links'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Static reference extraction over a record's fields — the pure core of the `record_refs` durable index
|
|
10
|
+
* and the derived stale-reference warnings. Walks every reference-bearing field type (relation, media,
|
|
11
|
+
* internal link, richtext internal-link markers, repeater) plus the block tree, and yields the
|
|
12
|
+
* `(collection, id)` targets it points at. PURE (no DB / no Nuxt) so it unit-tests hard.
|
|
13
|
+
*/
|
|
14
|
+
export interface FieldRef {
|
|
15
|
+
collection: string
|
|
16
|
+
id: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A ref tagged with WHERE it lives, for the editor's per-field / per-block dead-reference notes. */
|
|
20
|
+
export interface LocatedRef extends FieldRef {
|
|
21
|
+
/** The top-level field key (a repeater attributes all its entries' refs to the repeater key). */
|
|
22
|
+
field: string
|
|
23
|
+
/** The id of the block node the ref lives in, or undefined at the record root. */
|
|
24
|
+
blockId?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// `KeyMode` (columns vs props storage asymmetry) is owned by core's populate seam and imported above —
|
|
28
|
+
// the same asymmetry the field-tree populator walks, so both share one definition.
|
|
29
|
+
|
|
30
|
+
/** The internal-link guard, reimplemented locally to keep this `core`/`fields`-side extractor from
|
|
31
|
+
* importing the `public` layer's private `isInternal` (which would invert the layer dependency). */
|
|
32
|
+
function internalLinkRef(value: unknown): FieldRef | null {
|
|
33
|
+
if (!value || typeof value !== 'object') return null
|
|
34
|
+
const v = value as Record<string, unknown>
|
|
35
|
+
if (v.type === 'internal' && typeof v.collection === 'string' && typeof v.id === 'number') {
|
|
36
|
+
return { collection: v.collection, id: v.id }
|
|
37
|
+
}
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Located refs inside one flat value bag (top-level row | block props | a repeater entry). */
|
|
42
|
+
function bagRefs(fields: Record<string, FieldDef>, bag: Record<string, unknown>, keyMode: KeyMode): LocatedRef[] {
|
|
43
|
+
const out: LocatedRef[] = []
|
|
44
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
45
|
+
// `if (fieldIs(...))` not `switch (field.type)`: the open consumer arm makes `type` a non-discriminant,
|
|
46
|
+
// so a switch wouldn't narrow `field` to its arm. (text / number / boolean / datetime / choice / json
|
|
47
|
+
// carry no typed references, so they're not handled here.)
|
|
48
|
+
if (fieldIs(field, 'relation')) {
|
|
49
|
+
const coll = field.relation.collection
|
|
50
|
+
if (field.relation.many) {
|
|
51
|
+
const ids = bag[key]
|
|
52
|
+
if (Array.isArray(ids)) for (const id of ids) if (typeof id === 'number') out.push({ field: key, collection: coll, id })
|
|
53
|
+
} else {
|
|
54
|
+
const value = bag[keyMode === 'columns' ? `${key}Id` : key]
|
|
55
|
+
if (typeof value === 'number') out.push({ field: key, collection: coll, id: value })
|
|
56
|
+
}
|
|
57
|
+
} else if (fieldIs(field, 'media')) {
|
|
58
|
+
if (field.options?.multiple) {
|
|
59
|
+
const ids = bag[key]
|
|
60
|
+
if (Array.isArray(ids)) for (const id of ids) if (typeof id === 'number') out.push({ field: key, collection: 'media', id })
|
|
61
|
+
} else {
|
|
62
|
+
const value = bag[keyMode === 'columns' ? `${key}Id` : key]
|
|
63
|
+
if (typeof value === 'number') out.push({ field: key, collection: 'media', id: value })
|
|
64
|
+
}
|
|
65
|
+
} else if (field.type === 'link') {
|
|
66
|
+
const ref = internalLinkRef(bag[key])
|
|
67
|
+
if (ref) out.push({ field: key, collection: ref.collection, id: ref.id })
|
|
68
|
+
} else if (field.type === 'richtext') {
|
|
69
|
+
const value = bag[key]
|
|
70
|
+
if (typeof value === 'string') for (const r of collectRichtextRefs(value)) out.push({ field: key, collection: r.collection, id: r.id })
|
|
71
|
+
} else if (fieldIs(field, 'repeater')) {
|
|
72
|
+
const entries = bag[key]
|
|
73
|
+
if (Array.isArray(entries)) {
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
if (entry && typeof entry === 'object') {
|
|
76
|
+
// A repeater entry is keyed bare (props mode); attribute its refs to the repeater field.
|
|
77
|
+
for (const r of bagRefs(field.options.fields, entry as Record<string, unknown>, 'props')) {
|
|
78
|
+
out.push({ field: key, collection: r.collection, id: r.id })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Located refs inside a block `content` array, recursing slots; each ref carries the innermost block id. */
|
|
89
|
+
function blockRefs(content: unknown): LocatedRef[] {
|
|
90
|
+
const out: LocatedRef[] = []
|
|
91
|
+
const walk = (nodes: unknown): void => {
|
|
92
|
+
if (!Array.isArray(nodes)) return
|
|
93
|
+
for (const node of nodes) {
|
|
94
|
+
if (!node || typeof node !== 'object') continue
|
|
95
|
+
const n = node as { id?: string; type?: string; props?: Record<string, unknown>; slots?: Record<string, unknown> }
|
|
96
|
+
const def = n.type ? getBlock(n.type) : undefined
|
|
97
|
+
if (def && n.props && typeof n.props === 'object') {
|
|
98
|
+
for (const r of bagRefs(def.fields, n.props, 'props')) out.push({ field: r.field, blockId: n.id, collection: r.collection, id: r.id })
|
|
99
|
+
}
|
|
100
|
+
if (n.slots && typeof n.slots === 'object') {
|
|
101
|
+
for (const slotNodes of Object.values(n.slots)) walk(slotNodes)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
walk(content)
|
|
106
|
+
return out
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function dedupe(refs: FieldRef[]): FieldRef[] {
|
|
110
|
+
const seen = new Set<string>()
|
|
111
|
+
const out: FieldRef[] = []
|
|
112
|
+
for (const r of refs) {
|
|
113
|
+
const k = `${r.collection}:${r.id}`
|
|
114
|
+
if (!seen.has(k)) { seen.add(k); out.push({ collection: r.collection, id: r.id }) }
|
|
115
|
+
}
|
|
116
|
+
return out
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function dedupeLocated(refs: LocatedRef[]): LocatedRef[] {
|
|
120
|
+
const seen = new Set<string>()
|
|
121
|
+
const out: LocatedRef[] = []
|
|
122
|
+
for (const r of refs) {
|
|
123
|
+
const k = `${r.blockId ?? ''} ${r.field} ${r.collection}:${r.id}`
|
|
124
|
+
if (!seen.has(k)) { seen.add(k); out.push(r) }
|
|
125
|
+
}
|
|
126
|
+
return out
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Deduped `(collection,id)` refs in a single flat value bag (one field set), honouring the key-mode. */
|
|
130
|
+
export function extractFieldRefs(fields: Record<string, FieldDef>, bag: Record<string, unknown>, keyMode: KeyMode): FieldRef[] {
|
|
131
|
+
return dedupe(bagRefs(fields, bag, keyMode))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Every distinct `(collection,id)` a record points at — its top-level columns plus, if blocks are
|
|
135
|
+
* enabled, its whole block tree. The forward edges stored in `record_refs`. */
|
|
136
|
+
export function extractRecordRefs(def: CollectionDef, row: Record<string, unknown>): FieldRef[] {
|
|
137
|
+
return dedupe(extractLocatedRecordRefs(def, row))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Like `extractRecordRefs` but each ref carries its field + block-node location (for editor warnings). */
|
|
141
|
+
export function extractLocatedRecordRefs(def: CollectionDef, row: Record<string, unknown>): LocatedRef[] {
|
|
142
|
+
const refs = bagRefs(def.fields, row, 'columns')
|
|
143
|
+
// The `seo` system column carries a media ref (the social image) — track it like any media field so
|
|
144
|
+
// dead-link warnings and the published-media GC see a record whose ONLY use of an image is og:image.
|
|
145
|
+
if (def.seo && row.seo && typeof row.seo === 'object') {
|
|
146
|
+
for (const r of bagRefs(seoPopulateFields, row.seo as Record<string, unknown>, 'props')) {
|
|
147
|
+
refs.push({ ...r, field: `seo.${r.field}` })
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (def.blocks?.enabled) refs.push(...blockRefs(row.content))
|
|
151
|
+
return dedupeLocated(refs)
|
|
152
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { FieldDef } from '../../../core/server/utils/defineCollection'
|
|
2
|
+
import { fieldIs } from '../../../core/server/utils/defineCollection'
|
|
3
|
+
import { seoPopulateFields } from '../../../core/server/utils/seo'
|
|
4
|
+
import type { Populator, PopulateCtx, KeyMode, FieldPopulator } from '../../../core/server/utils/populate'
|
|
5
|
+
import { getFieldPopulator } from '../../../core/server/utils/populate'
|
|
6
|
+
import { buildBlockPopulator } from './block-populate'
|
|
7
|
+
|
|
8
|
+
/** How the walker finds the populator for a field type — the global registry by default, or an explicit
|
|
9
|
+
* map (used by tests + the media/link whole-row compatibility builders to run one populator in isolation). */
|
|
10
|
+
export type PopulatorLookup = (type: string) => FieldPopulator | undefined
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Apply the registered per-field-type populators over ONE flat value bag (top-level row | block props | a
|
|
14
|
+
* repeater entry). For each field: dispatch to its type's populator (which reads by `keyMode` and mutates
|
|
15
|
+
* `bag`), then — for a repeater — clone its entries and recurse in PROPS mode. Cloning the array + entries
|
|
16
|
+
* keeps the ORIGINAL row untouched (population is non-destructive). Mutates `bag` in place. This is the
|
|
17
|
+
* `bagRefs` recursion (see `extract-refs.ts`), generalised from "collect refs" to "run a visitor".
|
|
18
|
+
*/
|
|
19
|
+
export function applyFieldPopulators(
|
|
20
|
+
bag: Record<string, unknown>,
|
|
21
|
+
fields: Record<string, FieldDef>,
|
|
22
|
+
keyMode: KeyMode,
|
|
23
|
+
ctx: PopulateCtx,
|
|
24
|
+
lookup: PopulatorLookup,
|
|
25
|
+
): void {
|
|
26
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
27
|
+
// A per-instance `field.populate` (Pruvious `additional.population`) wins over the type's default.
|
|
28
|
+
const fn = field.populate ?? lookup(field.type)
|
|
29
|
+
if (fn) fn(bag, key, field, ctx, keyMode)
|
|
30
|
+
if (fieldIs(field, 'repeater')) {
|
|
31
|
+
const entries = bag[key]
|
|
32
|
+
if (Array.isArray(entries)) {
|
|
33
|
+
const cloned = entries.map((e) => (e && typeof e === 'object' ? { ...(e as Record<string, unknown>) } : e))
|
|
34
|
+
bag[key] = cloned
|
|
35
|
+
for (const entry of cloned) {
|
|
36
|
+
if (entry && typeof entry === 'object') {
|
|
37
|
+
applyFieldPopulators(entry as Record<string, unknown>, field.options.fields, 'props', ctx, lookup)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The single global row populator: walks a row's top-level fields (COLUMNS mode) and, when blocks are
|
|
47
|
+
* enabled, its block tree (each node's props in PROPS mode + every slot), dispatching each field to its
|
|
48
|
+
* registered per-type populator and recursing repeaters. Non-destructive (returns a fresh row; block
|
|
49
|
+
* nodes/props are cloned by `buildBlockPopulator`, repeater entries by `applyFieldPopulators`).
|
|
50
|
+
*/
|
|
51
|
+
export function buildFieldTreePopulator(lookup: PopulatorLookup = getFieldPopulator): Populator {
|
|
52
|
+
return (row, ctx) => {
|
|
53
|
+
const out: Record<string, unknown> = { ...row }
|
|
54
|
+
applyFieldPopulators(out, ctx.def.fields, 'columns', ctx, lookup)
|
|
55
|
+
// The `seo` system column carries a media reference (the social image) — walk it like a props bag
|
|
56
|
+
// so the id resolves under `seo.$media.image` with the same read-capture as any media field.
|
|
57
|
+
if (ctx.def.seo && out.seo && typeof out.seo === 'object') {
|
|
58
|
+
const seoBag = { ...(out.seo as Record<string, unknown>) }
|
|
59
|
+
applyFieldPopulators(seoBag, seoPopulateFields, 'props', ctx, lookup)
|
|
60
|
+
out.seo = seoBag
|
|
61
|
+
}
|
|
62
|
+
if (ctx.def.blocks?.enabled && Array.isArray(out.content)) {
|
|
63
|
+
const walk = buildBlockPopulator((props, fields) => applyFieldPopulators(props, fields, 'props', ctx, lookup))
|
|
64
|
+
out.content = walk(out.content)
|
|
65
|
+
}
|
|
66
|
+
return out
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useBlurUp } from '../composables/useBlurUp'
|
|
3
|
+
import { useMediaVariant } from '../composables/useMediaVariant'
|
|
4
|
+
import type { ResolvedMedia } from '../../server/utils/resolve'
|
|
5
|
+
import type { VariantFit, VariantFormat } from '../../../core/server/utils/kestrel-config'
|
|
6
|
+
|
|
7
|
+
// The engine's responsive-image component: it renders a <picture> from the media's generated derivatives
|
|
8
|
+
// AND its usage declares (to the generate-time scan) which sizes/formats are actually needed — "not in the
|
|
9
|
+
// code ⇒ not generated". One usage may declare MULTIPLE `widths` (proportional) and/or a fixed `crop`, plus
|
|
10
|
+
// named config `preset`s, crossed with `formats`. `priority` marks the LCP image (eager + high fetchpriority;
|
|
11
|
+
// no `sizes=auto`, which only applies to lazy images). `sizes="auto"` degrades gracefully (see resolveSizes).
|
|
12
|
+
const props = defineProps<{
|
|
13
|
+
media: ResolvedMedia | null | undefined
|
|
14
|
+
widths?: number[]
|
|
15
|
+
crop?: { width: number; height: number; fit?: VariantFit }
|
|
16
|
+
preset?: string | string[]
|
|
17
|
+
formats?: VariantFormat[]
|
|
18
|
+
sizes?: string
|
|
19
|
+
priority?: boolean
|
|
20
|
+
}>()
|
|
21
|
+
|
|
22
|
+
const { model } = useMediaVariant(
|
|
23
|
+
() => props.media,
|
|
24
|
+
() => ({ widths: props.widths, crop: props.crop, preset: props.preset, formats: props.formats, sizes: props.sizes }),
|
|
25
|
+
{ priority: () => props.priority ?? false },
|
|
26
|
+
)
|
|
27
|
+
const { imgEl, animate, placeholderStyle, onLoad } = useBlurUp(() => props.media?.thumbhash)
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<picture v-if="model">
|
|
32
|
+
<source v-for="s in model.sources" :key="s.type" :type="s.type" :srcset="s.srcset" :sizes="model.sizes">
|
|
33
|
+
<img
|
|
34
|
+
ref="imgEl"
|
|
35
|
+
:src="model.src"
|
|
36
|
+
:sizes="model.sizes"
|
|
37
|
+
:width="model.width ?? undefined"
|
|
38
|
+
:height="model.height ?? undefined"
|
|
39
|
+
:alt="model.alt"
|
|
40
|
+
:class="{ 'kestrel-img--in': animate }"
|
|
41
|
+
:style="placeholderStyle"
|
|
42
|
+
:loading="priority ? 'eager' : 'lazy'"
|
|
43
|
+
:fetchpriority="priority ? 'high' : undefined"
|
|
44
|
+
decoding="async"
|
|
45
|
+
@load="onLoad"
|
|
46
|
+
>
|
|
47
|
+
</picture>
|
|
48
|
+
</template>
|
|
49
|
+
|
|
50
|
+
<style scoped>
|
|
51
|
+
/* Sharpen-in: one-shot blur→crisp on a genuine async load (JS-driven, so SSR / no-JS / cached just show
|
|
52
|
+
the image). Same treatment as the demo MediaImage. */
|
|
53
|
+
@keyframes kestrel-img-sharpen {
|
|
54
|
+
from { filter: blur(8px); }
|
|
55
|
+
to { filter: blur(0); }
|
|
56
|
+
}
|
|
57
|
+
.kestrel-img--in {
|
|
58
|
+
animation: kestrel-img-sharpen 0.5s ease-out;
|
|
59
|
+
}
|
|
60
|
+
@media (prefers-reduced-motion: reduce) {
|
|
61
|
+
.kestrel-img--in {
|
|
62
|
+
animation: none;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
</style>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import type { Conflict } from '../utils/ops'
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{ open: boolean; conflicts: Conflict[]; type: 'move' | 'copy'; dest: string; busy?: boolean; error?: string | null }>()
|
|
6
|
+
const emit = defineEmits<{ resolve: ['skip' | 'overwrite' | 'rename']; 'update:open': [boolean] }>()
|
|
7
|
+
|
|
8
|
+
const { t } = useT()
|
|
9
|
+
const title = computed(() => props.type === 'move' ? t('media.conflictTitleMove') : t('media.conflictTitleCopy'))
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<UiDialog :open="open" :title="title" @update:open="(v) => emit('update:open', v)">
|
|
14
|
+
<p>{{ t('media.conflictItems', { count: conflicts.length, dest: dest || t('media.rootFolder') }) }}</p>
|
|
15
|
+
<ul class="media-conflict__list">
|
|
16
|
+
<li v-for="c in conflicts" :key="c.targetPath">{{ c.targetPath }} — {{ c.type === 'folder-exists' ? t('media.conflictFolderExists') : t('media.conflictFileExists') }}</li>
|
|
17
|
+
</ul>
|
|
18
|
+
<UiAlert v-if="error" variant="error">{{ error }}</UiAlert>
|
|
19
|
+
<template #footer>
|
|
20
|
+
<UiButton :disabled="busy" @click="emit('update:open', false)">{{ t('common.cancel') }}</UiButton>
|
|
21
|
+
<UiButton :disabled="busy" @click="emit('resolve', 'skip')">{{ t('media.skip') }}</UiButton>
|
|
22
|
+
<UiButton :disabled="busy" @click="emit('resolve', 'overwrite')">{{ t('media.replace') }}</UiButton>
|
|
23
|
+
<UiButton variant="primary" :disabled="busy" @click="emit('resolve', 'rename')">{{ t('media.keepBoth') }}</UiButton>
|
|
24
|
+
</template>
|
|
25
|
+
</UiDialog>
|
|
26
|
+
</template>
|
|
27
|
+
|
|
28
|
+
<style lang="scss" scoped>
|
|
29
|
+
.media-conflict__list {
|
|
30
|
+
margin: var(--space-2) 0 0;
|
|
31
|
+
padding-left: var(--space-4);
|
|
32
|
+
font-size: var(--text-sm);
|
|
33
|
+
color: var(--color-text-muted);
|
|
34
|
+
}
|
|
35
|
+
</style>
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import { humanizeSize } from '../utils/library'
|
|
4
|
+
import type { DeleteReport } from '../utils/ops'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{ open: boolean; report: DeleteReport | null; names: Record<number, string>; busy?: boolean; error?: string | null }>()
|
|
7
|
+
const emit = defineEmits<{ confirm: []; 'update:open': [boolean] }>()
|
|
8
|
+
|
|
9
|
+
const { t } = useT()
|
|
10
|
+
|
|
11
|
+
const usageLines = computed(() => {
|
|
12
|
+
const out: { file: string; refs: string }[] = []
|
|
13
|
+
for (const [id, refs] of Object.entries(props.report?.usages ?? {})) {
|
|
14
|
+
if (refs.length) out.push({ file: props.names[Number(id)] ?? `#${id}`, refs: refs.map((u) => `${u.collection} #${u.recordId} (${u.field})`).join(', ') })
|
|
15
|
+
}
|
|
16
|
+
return out
|
|
17
|
+
})
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<UiDialog :open="open" :title="t('common.delete')" @update:open="(v) => emit('update:open', v)">
|
|
22
|
+
<p v-if="report">
|
|
23
|
+
{{ t('media.deleteSummary', {
|
|
24
|
+
files: report.summary.files,
|
|
25
|
+
filesUnit: report.summary.files === 1 ? t('media.file') : t('media.files'),
|
|
26
|
+
folders: report.summary.folders,
|
|
27
|
+
foldersUnit: report.summary.folders === 1 ? t('media.folder') : t('media.folders'),
|
|
28
|
+
size: humanizeSize(report.summary.totalBytes)
|
|
29
|
+
}) }}
|
|
30
|
+
</p>
|
|
31
|
+
<div v-if="usageLines.length" class="media-delete__usages">
|
|
32
|
+
<p>{{ t('media.usageWarning') }}</p>
|
|
33
|
+
<ul>
|
|
34
|
+
<li v-for="u in usageLines" :key="u.file">{{ u.file }} → {{ u.refs }}</li>
|
|
35
|
+
</ul>
|
|
36
|
+
</div>
|
|
37
|
+
<UiAlert v-if="error" variant="error">{{ error }}</UiAlert>
|
|
38
|
+
<template #footer>
|
|
39
|
+
<UiButton variant="ghost" :disabled="busy" @click="emit('update:open', false)">{{ t('common.cancel') }}</UiButton>
|
|
40
|
+
<UiButton variant="danger" :disabled="busy" @click="emit('confirm')">{{ t('common.delete') }}</UiButton>
|
|
41
|
+
</template>
|
|
42
|
+
</UiDialog>
|
|
43
|
+
</template>
|
|
44
|
+
|
|
45
|
+
<style lang="scss" scoped>
|
|
46
|
+
.media-delete__usages {
|
|
47
|
+
margin-top: var(--space-3);
|
|
48
|
+
color: var(--color-danger);
|
|
49
|
+
font-size: var(--text-sm);
|
|
50
|
+
ul { margin: var(--space-1) 0 0; padding-left: var(--space-4); }
|
|
51
|
+
}
|
|
52
|
+
</style>
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { itemKey, humanizeSize, type LibraryFile, type LibraryItem } from '../utils/library'
|
|
3
|
+
defineProps<{ items: LibraryItem[]; isSelected: (item: LibraryItem) => boolean; dropTargetPath?: string | null; parentPath?: string | null; upLabel?: string }>()
|
|
4
|
+
const emit = defineEmits<{ navigate: [string]; select: [LibraryItem, { toggle: boolean; range: boolean }]; open: [LibraryItem]; dragstart: [LibraryItem, DragEvent]; dragend: [] }>()
|
|
5
|
+
const mods = (e: MouseEvent) => ({ toggle: e.ctrlKey || e.metaKey, range: e.shiftKey })
|
|
6
|
+
// Plain click opens the folder; modifier-click selects it so folders can join the selection.
|
|
7
|
+
function onFolder(item: LibraryItem, path: string, e: MouseEvent) {
|
|
8
|
+
if (e.ctrlKey || e.metaKey || e.shiftKey) emit('select', item, mods(e))
|
|
9
|
+
else emit('navigate', path)
|
|
10
|
+
}
|
|
11
|
+
function onFile(item: LibraryItem, e: MouseEvent) { emit('select', item, mods(e)) }
|
|
12
|
+
const ext = (filename: string) => (filename.split('.').pop() ?? '').toUpperCase()
|
|
13
|
+
// Hover meta: dimensions (when known) · size — the same facts the table view shows in its columns.
|
|
14
|
+
function fileMeta(f: LibraryFile): string {
|
|
15
|
+
const parts: string[] = []
|
|
16
|
+
if (f.width && f.height) parts.push(`${f.width}×${f.height}`)
|
|
17
|
+
parts.push(humanizeSize(f.size))
|
|
18
|
+
return parts.join(' · ')
|
|
19
|
+
}
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<ul class="media-grid">
|
|
24
|
+
<li v-if="parentPath != null" class="media-grid__cell">
|
|
25
|
+
<!-- Pure up-navigation; not selectable/draggable (kept out of `items`). -->
|
|
26
|
+
<button type="button" class="media-grid__tile media-grid__tile--folder" data-test="folder-up"
|
|
27
|
+
:aria-label="upLabel" @click="emit('navigate', parentPath)">
|
|
28
|
+
<span class="media-grid__thumb-wrap">
|
|
29
|
+
<span class="media-grid__thumb media-grid__thumb--folder" aria-hidden="true"><UiIcon name="folder" :size="40" /></span>
|
|
30
|
+
</span>
|
|
31
|
+
<span class="media-grid__name">..</span>
|
|
32
|
+
</button>
|
|
33
|
+
</li>
|
|
34
|
+
<li v-for="item in items" :key="itemKey(item)" class="media-grid__cell">
|
|
35
|
+
<button v-if="item.type === 'folder'" type="button" class="media-grid__tile media-grid__tile--folder"
|
|
36
|
+
:data-test="`folder-${item.folder.path}`" :aria-pressed="isSelected(item)"
|
|
37
|
+
:data-drop-folder="item.folder.path"
|
|
38
|
+
:class="{ 'is-selected': isSelected(item), 'is-drop-target': item.folder.path === dropTargetPath }"
|
|
39
|
+
draggable="true"
|
|
40
|
+
@click="onFolder(item, item.folder.path, $event)"
|
|
41
|
+
@keydown.space.prevent="emit('select', item, { toggle: true, range: false })"
|
|
42
|
+
@dragstart="(e) => emit('dragstart', item, e)"
|
|
43
|
+
@dragend="emit('dragend')">
|
|
44
|
+
<span class="media-grid__thumb-wrap">
|
|
45
|
+
<span class="media-grid__thumb media-grid__thumb--folder" aria-hidden="true"><UiIcon name="folder" :size="40" /></span>
|
|
46
|
+
<span v-if="item.folder.size != null" class="media-grid__meta" aria-hidden="true">{{ humanizeSize(item.folder.size) }}</span>
|
|
47
|
+
</span>
|
|
48
|
+
<span class="media-grid__name">{{ item.folder.name }}</span>
|
|
49
|
+
</button>
|
|
50
|
+
<button v-else type="button" class="media-grid__tile media-grid__tile--file"
|
|
51
|
+
:data-test="`file-${item.file.id}`" :aria-pressed="isSelected(item)"
|
|
52
|
+
:data-file-id="item.file.id"
|
|
53
|
+
:class="{ 'is-selected': isSelected(item) }"
|
|
54
|
+
draggable="true"
|
|
55
|
+
@click="onFile(item, $event)"
|
|
56
|
+
@dblclick="emit('open', item)"
|
|
57
|
+
@keydown.enter.prevent="emit('open', item)"
|
|
58
|
+
@dragstart="(e) => emit('dragstart', item, e)"
|
|
59
|
+
@dragend="emit('dragend')">
|
|
60
|
+
<span class="media-grid__thumb-wrap">
|
|
61
|
+
<!-- Show a thumbnail for any image WITH a source: media files carry `srcset` (responsive derivatives),
|
|
62
|
+
while a consumer feeding object-URLs (e.g. the secure gallery) has only `src`. Non-images → badge. -->
|
|
63
|
+
<MediaThumb v-if="item.file.mime?.startsWith('image/') && (item.file.srcset || item.file.src)" class="media-grid__thumb"
|
|
64
|
+
:src="item.file.src" :srcset="item.file.srcset" sizes="160px" :alt="item.file.alt ?? item.file.filename" :thumbhash="item.file.thumbhash" />
|
|
65
|
+
<span v-else class="media-grid__badge" aria-hidden="true">{{ ext(item.file.filename) }}</span>
|
|
66
|
+
<span class="media-grid__meta" aria-hidden="true">{{ fileMeta(item.file) }}</span>
|
|
67
|
+
<!-- Optional per-file overlay (e.g. proofing colour flags). Empty by default → nothing rendered. -->
|
|
68
|
+
<span class="media-grid__overlay"><slot name="file-overlay" :file="item.file" /></span>
|
|
69
|
+
</span>
|
|
70
|
+
<span class="media-grid__name">{{ item.file.filename }}</span>
|
|
71
|
+
</button>
|
|
72
|
+
</li>
|
|
73
|
+
</ul>
|
|
74
|
+
</template>
|
|
75
|
+
|
|
76
|
+
<style lang="scss" scoped>
|
|
77
|
+
.media-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr)); gap: var(--space-3); list-style: none; padding: 0; margin: 0; }
|
|
78
|
+
.media-grid__tile { display: flex; flex-direction: column; gap: var(--space-2); width: 100%; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: var(--space-2); cursor: pointer; text-align: left; }
|
|
79
|
+
.media-grid__tile:hover { border-color: var(--color-border-strong); }
|
|
80
|
+
// Selection/drop/focus rings are INSET (border-color + inset shadow, or a negative outline-offset) so a
|
|
81
|
+
// scrolling ancestor never clips them — matches MediaTable. An outset outline (positive offset) on an
|
|
82
|
+
// edge tile is sliced off by the library's overflow.
|
|
83
|
+
.media-grid__tile.is-selected { border-color: var(--color-primary); box-shadow: inset 0 0 0 1px var(--color-primary); }
|
|
84
|
+
.media-grid__tile.is-drop-target { outline: 2px dashed var(--color-primary); outline-offset: -2px; background: var(--color-surface); }
|
|
85
|
+
.media-grid__tile:focus-visible { outline: 2px solid var(--color-focus); outline-offset: -2px; }
|
|
86
|
+
.media-grid__thumb-wrap { position: relative; width: 100%; }
|
|
87
|
+
.media-grid__overlay { position: absolute; top: var(--space-1); left: var(--space-1); display: flex; gap: 2px; pointer-events: none; }
|
|
88
|
+
.media-grid__thumb { aspect-ratio: 1; width: 100%; object-fit: cover; border-radius: var(--radius-sm); background: var(--color-bg); display: block; }
|
|
89
|
+
.media-grid__thumb--folder { display: grid; place-items: center; color: var(--color-text-subtle); }
|
|
90
|
+
.media-grid__badge { display: grid; place-items: center; aspect-ratio: 1; width: 100%; border-radius: var(--radius-sm); background: var(--color-bg); color: var(--color-text-muted); font-weight: var(--weight-bold); }
|
|
91
|
+
// Hover/focus reveal of the dimensions · size caption, overlaid on the lower edge of the thumb.
|
|
92
|
+
.media-grid__meta {
|
|
93
|
+
position: absolute;
|
|
94
|
+
inset-inline: 0;
|
|
95
|
+
bottom: 0;
|
|
96
|
+
padding: 2px var(--space-1);
|
|
97
|
+
font-size: var(--text-xs);
|
|
98
|
+
line-height: 1.35;
|
|
99
|
+
text-align: center;
|
|
100
|
+
white-space: nowrap;
|
|
101
|
+
overflow: hidden;
|
|
102
|
+
text-overflow: ellipsis;
|
|
103
|
+
color: var(--color-on-primary);
|
|
104
|
+
background: linear-gradient(transparent, var(--color-scrim));
|
|
105
|
+
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
|
|
106
|
+
opacity: 0;
|
|
107
|
+
transition: opacity var(--motion-fast) var(--ease-standard);
|
|
108
|
+
pointer-events: none;
|
|
109
|
+
}
|
|
110
|
+
.media-grid__tile:hover .media-grid__meta,
|
|
111
|
+
.media-grid__tile:focus-visible .media-grid__meta { opacity: 1; }
|
|
112
|
+
.media-grid__name { font-size: var(--text-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
113
|
+
</style>
|