@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,214 @@
|
|
|
1
|
+
import type { SerializedBlock } from '../../../core/server/utils/serialize-collection'
|
|
2
|
+
import { reorder } from '../../../ui/app/utils/reorder'
|
|
3
|
+
import { initialValues } from './edit-form'
|
|
4
|
+
|
|
5
|
+
// Pure, immutable operations over a nested block tree (`content`), addressed by each block's stable
|
|
6
|
+
// `id` rather than a positional index — this is what lets a single shared selection + one fields pane
|
|
7
|
+
// edit a block at ANY depth: every op walks the whole tree by id and rebuilds only the path to it. No
|
|
8
|
+
// Vue here — fully unit-testable.
|
|
9
|
+
|
|
10
|
+
export interface BlockRow {
|
|
11
|
+
id: string
|
|
12
|
+
type: string
|
|
13
|
+
props: Record<string, unknown>
|
|
14
|
+
slots?: Record<string, unknown>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type GenId = () => string
|
|
18
|
+
|
|
19
|
+
/** Stable config + id-addressed operations for the tree pane, threaded through the recursion as one
|
|
20
|
+
* object. Lives here (not in the SFC) so the component, the editor shell, and tests can share the type. */
|
|
21
|
+
export interface BlockTreeCtx {
|
|
22
|
+
byName: Record<string, SerializedBlock>
|
|
23
|
+
allowedTypes: SerializedBlock[]
|
|
24
|
+
ops: {
|
|
25
|
+
select: (id: string | null) => void
|
|
26
|
+
add: (parentId: string | null, slotName: string | null, type: string) => void
|
|
27
|
+
remove: (id: string) => void
|
|
28
|
+
move: (id: string, dir: -1 | 1) => void
|
|
29
|
+
duplicate: (id: string) => void
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A seeded blank block of `type`: fresh id + props defaulted from the type's field schema. */
|
|
34
|
+
export function blankBlock(type: string, schemas: Record<string, SerializedBlock>, genId: GenId): BlockRow {
|
|
35
|
+
return { id: genId(), type, props: initialValues(schemas[type]?.fields ?? {}) }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Deep-clone a block subtree with fresh ids at every level (duplicate never shares an id or a ref). */
|
|
39
|
+
export function cloneBlockTree(b: BlockRow, genId: GenId): BlockRow {
|
|
40
|
+
const copy: BlockRow = { ...b, id: genId(), props: JSON.parse(JSON.stringify(b.props ?? {})) }
|
|
41
|
+
if (b.slots) {
|
|
42
|
+
copy.slots = Object.fromEntries(
|
|
43
|
+
Object.entries(b.slots).map(([name, arr]) => [name, Array.isArray(arr) ? (arr as BlockRow[]).map((c) => cloneBlockTree(c, genId)) : arr]),
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
return copy
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface Found {
|
|
50
|
+
block: BlockRow
|
|
51
|
+
/** The array that directly contains the block. */
|
|
52
|
+
siblings: BlockRow[]
|
|
53
|
+
index: number
|
|
54
|
+
/** The id of the block whose slot contains this one, or null at the root level. */
|
|
55
|
+
parentId: string | null
|
|
56
|
+
/** The slot name this block lives in, or null at the root level. */
|
|
57
|
+
slotName: string | null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Locate a block anywhere in the tree by id (with its container + position), or null. */
|
|
61
|
+
export function findInTree(blocks: BlockRow[], id: string): Found | null {
|
|
62
|
+
function walk(arr: BlockRow[], parentId: string | null, slotName: string | null): Found | null {
|
|
63
|
+
for (let i = 0; i < arr.length; i++) {
|
|
64
|
+
const b = arr[i]!
|
|
65
|
+
if (b.id === id) return { block: b, siblings: arr, index: i, parentId, slotName }
|
|
66
|
+
if (b.slots) {
|
|
67
|
+
for (const [name, sub] of Object.entries(b.slots)) {
|
|
68
|
+
if (Array.isArray(sub)) {
|
|
69
|
+
const hit = walk(sub as BlockRow[], b.id, name)
|
|
70
|
+
if (hit) return hit
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
return walk(blocks, null, null)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Immutably transform the block with `id` (block → new block), rebuilding ONLY the path to it; every
|
|
81
|
+
* untouched block AND array keeps its reference. Returning the original `blocks` when nothing changed is
|
|
82
|
+
* what makes the recursive `next !== sub` a correct change-detector (a bare `.map` always allocates, so it
|
|
83
|
+
* would rebuild every slot-bearing block in the tree on any edit). */
|
|
84
|
+
function updateBlock(blocks: BlockRow[], id: string, fn: (b: BlockRow) => BlockRow): BlockRow[] {
|
|
85
|
+
let changed = false
|
|
86
|
+
const out = blocks.map((b) => {
|
|
87
|
+
if (b.id === id) { changed = true; return fn(b) }
|
|
88
|
+
if (!b.slots) return b
|
|
89
|
+
const mapped = withMappedSlots(b)
|
|
90
|
+
if (mapped !== b) changed = true
|
|
91
|
+
return mapped
|
|
92
|
+
})
|
|
93
|
+
return changed ? out : blocks
|
|
94
|
+
function withMappedSlots(b: BlockRow): BlockRow {
|
|
95
|
+
let changed = false
|
|
96
|
+
const slots: Record<string, unknown> = {}
|
|
97
|
+
for (const [name, sub] of Object.entries(b.slots!)) {
|
|
98
|
+
if (Array.isArray(sub)) {
|
|
99
|
+
const next = updateBlock(sub as BlockRow[], id, fn)
|
|
100
|
+
if (next !== sub) changed = true
|
|
101
|
+
slots[name] = next
|
|
102
|
+
} else slots[name] = sub
|
|
103
|
+
}
|
|
104
|
+
return changed ? { ...b, slots } : b
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Immutably transform the ARRAY that directly contains `id` (array+index → new array); every untouched
|
|
109
|
+
* block AND array keeps its reference (same same-ref-when-unchanged rule as `updateBlock`). */
|
|
110
|
+
function updateContaining(blocks: BlockRow[], id: string, fn: (arr: BlockRow[], index: number) => BlockRow[]): BlockRow[] {
|
|
111
|
+
const idx = blocks.findIndex((b) => b.id === id)
|
|
112
|
+
if (idx !== -1) return fn(blocks, idx)
|
|
113
|
+
let changed = false
|
|
114
|
+
const out = blocks.map((b) => {
|
|
115
|
+
if (!b.slots) return b
|
|
116
|
+
let slotChanged = false
|
|
117
|
+
const slots: Record<string, unknown> = {}
|
|
118
|
+
for (const [name, sub] of Object.entries(b.slots)) {
|
|
119
|
+
if (Array.isArray(sub)) {
|
|
120
|
+
const next = updateContaining(sub as BlockRow[], id, fn)
|
|
121
|
+
if (next !== sub) slotChanged = true
|
|
122
|
+
slots[name] = next
|
|
123
|
+
} else slots[name] = sub
|
|
124
|
+
}
|
|
125
|
+
if (!slotChanged) return b
|
|
126
|
+
changed = true
|
|
127
|
+
return { ...b, slots }
|
|
128
|
+
})
|
|
129
|
+
return changed ? out : blocks
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function updatePropById(blocks: BlockRow[], id: string, key: string, value: unknown): BlockRow[] {
|
|
133
|
+
return updateBlock(blocks, id, (b) => ({ ...b, props: { ...b.props, [key]: value } }))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Remove the block with `id` from wherever it lives. */
|
|
137
|
+
export function removeById(blocks: BlockRow[], id: string): BlockRow[] {
|
|
138
|
+
return updateContaining(blocks, id, (arr, i) => arr.filter((_, j) => j !== i))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Where selection should move when the block with `id` is removed: previous sibling, else next, else
|
|
142
|
+
* the parent (null at the root). Computed against the tree BEFORE removal. */
|
|
143
|
+
export function removalRetarget(blocks: BlockRow[], id: string): string | null {
|
|
144
|
+
const f = findInTree(blocks, id)
|
|
145
|
+
if (!f) return null
|
|
146
|
+
if (f.index > 0) return f.siblings[f.index - 1]!.id
|
|
147
|
+
if (f.index < f.siblings.length - 1) return f.siblings[f.index + 1]!.id
|
|
148
|
+
return f.parentId
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Move the block with `id` one step within its container (`dir` -1 up / +1 down); clamps at the edges. */
|
|
152
|
+
export function moveById(blocks: BlockRow[], id: string, dir: -1 | 1): BlockRow[] {
|
|
153
|
+
return updateContaining(blocks, id, (arr, i) => {
|
|
154
|
+
const to = i + dir
|
|
155
|
+
if (to < 0 || to >= arr.length) return arr
|
|
156
|
+
return reorder(arr, i, to)
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Duplicate the block with `id` (fresh ids throughout) right after it; returns the new tree + new id. */
|
|
161
|
+
export function duplicateById(blocks: BlockRow[], id: string, genId: GenId): { tree: BlockRow[]; newId: string } {
|
|
162
|
+
let newId = ''
|
|
163
|
+
const tree = updateContaining(blocks, id, (arr, i) => {
|
|
164
|
+
const copy = cloneBlockTree(arr[i]!, genId)
|
|
165
|
+
newId = copy.id
|
|
166
|
+
const next = [...arr]
|
|
167
|
+
next.splice(i + 1, 0, copy)
|
|
168
|
+
return next
|
|
169
|
+
})
|
|
170
|
+
return { tree, newId }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Append a seeded block of `type` at the root (`parentId` null) or into a parent's slot; returns the
|
|
174
|
+
* new tree + the new block's id. */
|
|
175
|
+
export function addBlock(
|
|
176
|
+
blocks: BlockRow[],
|
|
177
|
+
parentId: string | null,
|
|
178
|
+
slotName: string | null,
|
|
179
|
+
type: string,
|
|
180
|
+
schemas: Record<string, SerializedBlock>,
|
|
181
|
+
genId: GenId,
|
|
182
|
+
): { tree: BlockRow[]; newId: string } {
|
|
183
|
+
const block = blankBlock(type, schemas, genId)
|
|
184
|
+
if (parentId === null || slotName === null) return { tree: [...blocks, block], newId: block.id }
|
|
185
|
+
const tree = updateBlock(blocks, parentId, (parent) => {
|
|
186
|
+
const cur = Array.isArray(parent.slots?.[slotName]) ? (parent.slots![slotName] as BlockRow[]) : []
|
|
187
|
+
return { ...parent, slots: { ...(parent.slots ?? {}), [slotName]: [...cur, block] } }
|
|
188
|
+
})
|
|
189
|
+
return { tree, newId: block.id }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Ids of every block that itself has an error OR has a descendant with one (ancestor roll-up), for
|
|
193
|
+
* tree badges. `directIds` are the blocks the error map keys directly. */
|
|
194
|
+
export function errorBearingIds(blocks: BlockRow[], directIds: Set<string>): Set<string> {
|
|
195
|
+
const out = new Set<string>()
|
|
196
|
+
function walk(arr: BlockRow[]): boolean {
|
|
197
|
+
let any = false
|
|
198
|
+
for (const b of arr) {
|
|
199
|
+
let descendantHasError = false
|
|
200
|
+
if (b.slots) {
|
|
201
|
+
for (const sub of Object.values(b.slots)) {
|
|
202
|
+
if (Array.isArray(sub) && walk(sub as BlockRow[])) descendantHasError = true
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (directIds.has(b.id) || descendantHasError) {
|
|
206
|
+
out.add(b.id)
|
|
207
|
+
any = true
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return any
|
|
211
|
+
}
|
|
212
|
+
walk(blocks)
|
|
213
|
+
return out
|
|
214
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Wire shapes for the collection batch endpoint, shared by the ops composable and the delete dialog.
|
|
2
|
+
// Mirrors media/app/utils/ops.ts: one definition, no drift between the caller and the confirm UI.
|
|
3
|
+
|
|
4
|
+
/** The four batch actions the command endpoint accepts (a row action is a bulk op with one id). */
|
|
5
|
+
export type BatchAction = 'delete' | 'publish' | 'unpublish' | 'duplicate'
|
|
6
|
+
|
|
7
|
+
/** POST /api/{collection}/bulk response. For `duplicate`, `ids` are the CREATED rows. */
|
|
8
|
+
export interface BulkResult {
|
|
9
|
+
action: BatchAction
|
|
10
|
+
count: number
|
|
11
|
+
ids: number[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** GET /api/references/referrers?ids=1,2,3 response: id -> how many other records reference it.
|
|
15
|
+
* `checked` is false when the index lookup itself failed, in which case `counts` is empty because nothing
|
|
16
|
+
* could be read — NOT because nothing links to the selection. */
|
|
17
|
+
export interface ReferrerCounts {
|
|
18
|
+
counts: Record<string, number>
|
|
19
|
+
checked: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The pre-delete summary shown in the confirm dialog: how many rows, and how many of them other
|
|
23
|
+
* records still link to (aggregated from the referrer counts). */
|
|
24
|
+
export interface BatchDeleteReport {
|
|
25
|
+
/** How many rows are about to be deleted. */
|
|
26
|
+
count: number
|
|
27
|
+
/** How many of those rows are referenced by at least one other record. */
|
|
28
|
+
referencedCount: number
|
|
29
|
+
/** Per-id breakdown for the referenced rows (id + inbound referrer count), in input order. */
|
|
30
|
+
referenced: { id: number; referrers: number }[]
|
|
31
|
+
/** Whether the referrer lookup actually ran. `false` means the check FAILED (not "none found"), so
|
|
32
|
+
* the dialog must warn that inbound links are unverified rather than imply a safe delete. */
|
|
33
|
+
checked: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Fold the referrer-count map into a delete report — pure, so it is unit-testable without a network.
|
|
37
|
+
* `checked` is the lookup's own outcome and is carried through untouched: an empty `counts` is ambiguous
|
|
38
|
+
* on its own, and only this flag tells the dialog whether it may claim nothing links to the selection. */
|
|
39
|
+
export function buildDeleteReport(ids: number[], counts: Record<string, number>, checked: boolean): BatchDeleteReport {
|
|
40
|
+
const referenced = ids
|
|
41
|
+
.map((id) => ({ id, referrers: Number(counts[String(id)] ?? 0) }))
|
|
42
|
+
.filter((r) => r.referrers > 0)
|
|
43
|
+
return { count: ids.length, referencedCount: referenced.length, referenced, checked }
|
|
44
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Client-side shaping of the per-record dead-reference map (the `/dead-refs` endpoint payload) into the
|
|
2
|
+
// sets the editor renders: which block nodes carry a stale reference (fed through `errorBearingIds` for
|
|
3
|
+
// the tree roll-up, exactly like block errors) and which field keys are stale at a given location. Pure
|
|
4
|
+
// + Vue-free so it unit-tests directly (happy-dom can't render the teleported field widgets).
|
|
5
|
+
|
|
6
|
+
/** One stale reference a record holds, with its field/block location + why it is dead. Wire shape of the
|
|
7
|
+
* server's `LocatedDeadRef`; kept here so the admin client doesn't import the server (drizzle) module. */
|
|
8
|
+
export interface DeadRef {
|
|
9
|
+
field: string
|
|
10
|
+
blockId?: string
|
|
11
|
+
collection: string
|
|
12
|
+
id: number
|
|
13
|
+
reason: 'missing' | 'unpublished'
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Block-node ids that DIRECTLY hold a dead reference — feed into `errorBearingIds` for the tree roll-up. */
|
|
17
|
+
export function deadBlockIds(refs: DeadRef[]): Set<string> {
|
|
18
|
+
const out = new Set<string>()
|
|
19
|
+
for (const r of refs) if (r.blockId) out.add(r.blockId)
|
|
20
|
+
return out
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Field keys with a dead reference at a location: a block id, or `null` for the record/page root. */
|
|
24
|
+
export function deadFieldsAt(refs: DeadRef[], blockId: string | null): Set<string> {
|
|
25
|
+
const out = new Set<string>()
|
|
26
|
+
for (const r of refs) {
|
|
27
|
+
if (blockId === null ? !r.blockId : r.blockId === blockId) out.add(r.field)
|
|
28
|
+
}
|
|
29
|
+
return out
|
|
30
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { FieldDef } from '../../../core/server/utils/defineCollection'
|
|
2
|
+
import type { SerializedField } from '../../../core/server/utils/serialize-collection'
|
|
3
|
+
import { resolveFieldEmpty } from '../../../ui/app/utils/field-empty'
|
|
4
|
+
|
|
5
|
+
export interface ServerIssue {
|
|
6
|
+
path: (string | number)[]
|
|
7
|
+
message: string
|
|
8
|
+
code?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MappedErrors {
|
|
12
|
+
fields: Record<string, string>
|
|
13
|
+
form?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface FetchErrorInfo {
|
|
17
|
+
statusCode?: number
|
|
18
|
+
statusMessage?: string
|
|
19
|
+
issues: ServerIssue[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// The wire `SerializedField` is structurally what every Field* widget and `validateField` read
|
|
23
|
+
// (flat type + options record + relation); only its discriminated-union typing differs. Narrow here.
|
|
24
|
+
export function asFieldDef(field: SerializedField): FieldDef {
|
|
25
|
+
return field as unknown as FieldDef
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Read an ofetch/H3 error envelope: top-level statusCode/statusMessage, Zod issues nested at err.data.data.
|
|
29
|
+
export function readFetchError(e: unknown): FetchErrorInfo {
|
|
30
|
+
const err = e as { statusCode?: number; statusMessage?: string; data?: { data?: ServerIssue[] } & Record<string, unknown> }
|
|
31
|
+
const raw = err.data?.data ?? err.data
|
|
32
|
+
return {
|
|
33
|
+
statusCode: err.statusCode,
|
|
34
|
+
statusMessage: err.statusMessage,
|
|
35
|
+
issues: Array.isArray(raw) ? (raw as ServerIssue[]) : [],
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function cloneDefault(value: unknown): unknown {
|
|
40
|
+
return value !== null && typeof value === 'object' ? structuredClone(value) : value
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function emptyForField(field: SerializedField): unknown {
|
|
44
|
+
switch (field.type) {
|
|
45
|
+
case 'text':
|
|
46
|
+
return ''
|
|
47
|
+
case 'boolean':
|
|
48
|
+
return false
|
|
49
|
+
case 'repeater':
|
|
50
|
+
return []
|
|
51
|
+
case 'choice':
|
|
52
|
+
return field.options?.multiple ? [] : null
|
|
53
|
+
case 'relation':
|
|
54
|
+
return field.relation?.many ? [] : null
|
|
55
|
+
case 'media':
|
|
56
|
+
return field.options?.multiple ? [] : null
|
|
57
|
+
default:
|
|
58
|
+
// A consumer-defined type may register its blank shape (array/object-backed → [] / {}); else null.
|
|
59
|
+
return resolveFieldEmpty(field.type)?.() ?? null
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Build a blank form state: a present (JSON-safe) default wins, else a type-appropriate empty. */
|
|
64
|
+
export function initialValues(fields: Record<string, SerializedField>): Record<string, unknown> {
|
|
65
|
+
const out: Record<string, unknown> = {}
|
|
66
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
67
|
+
out[name] = 'default' in field ? cloneDefault(field.default) : emptyForField(field)
|
|
68
|
+
}
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Map a 400 body's Zod issues to per-field errors (by path[0]) and a single form-level error (empty path). */
|
|
73
|
+
export function mapServerErrors(issues: ServerIssue[] | null | undefined): MappedErrors {
|
|
74
|
+
const fields: Record<string, string> = {}
|
|
75
|
+
let form: string | undefined
|
|
76
|
+
for (const issue of issues ?? []) {
|
|
77
|
+
const head = issue.path?.[0]
|
|
78
|
+
if (head === undefined || head === null || head === '') {
|
|
79
|
+
if (form === undefined) form = issue.message
|
|
80
|
+
} else {
|
|
81
|
+
const name = String(head)
|
|
82
|
+
if (!(name in fields)) fields[name] = issue.message
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return form === undefined ? { fields } : { fields, form }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Per-block (keyed by the stable `block.id`), per-field error map for inline block-content errors. */
|
|
89
|
+
export type BlockErrorMap = Record<string, Record<string, string>>
|
|
90
|
+
|
|
91
|
+
interface BlockLike {
|
|
92
|
+
id?: unknown
|
|
93
|
+
props?: Record<string, unknown>
|
|
94
|
+
slots?: Record<string, unknown>
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function blockId(block: unknown): string | null {
|
|
98
|
+
const id = (block as BlockLike | undefined)?.id
|
|
99
|
+
return typeof id === 'string' && id !== '' ? id : null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Parse nested block-content issues into a per-block, per-field error map keyed by the block's
|
|
104
|
+
* stable `id`. The server reports a *position* path — `[key, i, 'props', field, …]` for a top-level
|
|
105
|
+
* block, `[key, i, 'slots', name, j, 'props', field, …]` for one nested in a slot (any depth). We
|
|
106
|
+
* walk that path through the submitted `content` to find the target block and pin the error to its
|
|
107
|
+
* id, so it survives a later reorder at any level. First wins per field; an unresolvable path is
|
|
108
|
+
* skipped.
|
|
109
|
+
*/
|
|
110
|
+
export function parseBlockErrors(
|
|
111
|
+
issues: ServerIssue[] | null | undefined,
|
|
112
|
+
content: readonly unknown[] | null | undefined,
|
|
113
|
+
key = 'content',
|
|
114
|
+
): BlockErrorMap {
|
|
115
|
+
const out: BlockErrorMap = {}
|
|
116
|
+
for (const issue of issues ?? []) {
|
|
117
|
+
const path = issue.path ?? []
|
|
118
|
+
if (path[0] !== key) continue
|
|
119
|
+
const hit = resolveBlockField(path.slice(1), content)
|
|
120
|
+
if (!hit) continue
|
|
121
|
+
out[hit.id] ??= {}
|
|
122
|
+
if (!(hit.field in out[hit.id]!)) out[hit.id]![hit.field] = issue.message
|
|
123
|
+
}
|
|
124
|
+
return out
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Walk a position path (after the `content` head) through the block tree to the block whose
|
|
129
|
+
* `props.<field>` the issue targets. Steps over `<index>, 'slots', <name>` segments into nested
|
|
130
|
+
* slot arrays. Returns the target block's stable id + the field name, or null if the path is not a
|
|
131
|
+
* `…props.<field>` leaf or any segment doesn't resolve to an identified block.
|
|
132
|
+
*/
|
|
133
|
+
function resolveBlockField(
|
|
134
|
+
path: readonly (string | number)[],
|
|
135
|
+
blocks: readonly unknown[] | null | undefined,
|
|
136
|
+
): { id: string; field: string } | null {
|
|
137
|
+
let arr: readonly unknown[] | null | undefined = blocks
|
|
138
|
+
for (let i = 0; i < path.length; ) {
|
|
139
|
+
const index = path[i]
|
|
140
|
+
if (typeof index !== 'number' || !Array.isArray(arr)) return null
|
|
141
|
+
const block = arr[index] as BlockLike | undefined
|
|
142
|
+
const next = path[i + 1]
|
|
143
|
+
if (next === 'props') {
|
|
144
|
+
const field = path[i + 2]
|
|
145
|
+
const id = blockId(block)
|
|
146
|
+
return typeof field === 'string' && id !== null ? { id, field } : null
|
|
147
|
+
}
|
|
148
|
+
if (next === 'slots') {
|
|
149
|
+
const name = path[i + 2]
|
|
150
|
+
if (typeof name !== 'string') return null
|
|
151
|
+
const slot = block?.slots?.[name]
|
|
152
|
+
arr = Array.isArray(slot) ? slot : null
|
|
153
|
+
i += 3
|
|
154
|
+
continue
|
|
155
|
+
}
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
return null
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Carry block errors across a `content` change. A block (identified by its stable `id`, top-level or nested in
|
|
163
|
+
* a slot at any depth) keeps its errors while it merely moves — a pure reorder, whether of its slot or of an
|
|
164
|
+
* ancestor — and is dropped when removed; added blocks never gain an error. Reconciliation is PER-FIELD: an
|
|
165
|
+
* edit clears only the edited field's stale message, so a still-invalid sibling field of the same block keeps
|
|
166
|
+
* its error instead of all of them flickering away until the next save.
|
|
167
|
+
*/
|
|
168
|
+
export function reconcileBlockErrors(
|
|
169
|
+
errors: BlockErrorMap,
|
|
170
|
+
prev: readonly unknown[] | null | undefined,
|
|
171
|
+
next: readonly unknown[] | null | undefined,
|
|
172
|
+
): BlockErrorMap {
|
|
173
|
+
const prevById = new Map<string, BlockLike>()
|
|
174
|
+
const nextById = new Map<string, BlockLike>()
|
|
175
|
+
flattenBlocksById(prev, prevById)
|
|
176
|
+
flattenBlocksById(next, nextById)
|
|
177
|
+
const out: BlockErrorMap = {}
|
|
178
|
+
for (const [id, errs] of Object.entries(errors)) {
|
|
179
|
+
const before = prevById.get(id)
|
|
180
|
+
const after = nextById.get(id)
|
|
181
|
+
if (!before || !after) continue // removed → drop
|
|
182
|
+
const beforeProps = (before.props ?? {}) as Record<string, unknown>
|
|
183
|
+
const afterProps = (after.props ?? {}) as Record<string, unknown>
|
|
184
|
+
const kept: Record<string, string> = {}
|
|
185
|
+
for (const [field, message] of Object.entries(errs)) {
|
|
186
|
+
// Keep a field's error only while THAT field's value is unchanged; editing it clears its own stale message.
|
|
187
|
+
if (valuesEqual(beforeProps[field], afterProps[field])) kept[field] = message
|
|
188
|
+
}
|
|
189
|
+
if (Object.keys(kept).length) out[id] = kept
|
|
190
|
+
}
|
|
191
|
+
return out
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Index every block in the tree (top-level and nested in slots) by its stable id. */
|
|
195
|
+
function flattenBlocksById(blocks: readonly unknown[] | null | undefined, into: Map<string, BlockLike>): void {
|
|
196
|
+
for (const block of blocks ?? []) {
|
|
197
|
+
const id = blockId(block)
|
|
198
|
+
if (id !== null) into.set(id, block as BlockLike)
|
|
199
|
+
const slots = (block as BlockLike | undefined)?.slots
|
|
200
|
+
if (slots) for (const arr of Object.values(slots)) if (Array.isArray(arr)) flattenBlocksById(arr, into)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Structural deep-equality for the value shapes the editor tracks (primitives, arrays, plain objects). */
|
|
205
|
+
export function valuesEqual(a: unknown, b: unknown): boolean {
|
|
206
|
+
if (a === b) return true
|
|
207
|
+
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false
|
|
208
|
+
|
|
209
|
+
const aArr = Array.isArray(a)
|
|
210
|
+
const bArr = Array.isArray(b)
|
|
211
|
+
if (aArr !== bArr) return false
|
|
212
|
+
if (aArr && bArr) {
|
|
213
|
+
if (a.length !== b.length) return false
|
|
214
|
+
return a.every((item, i) => valuesEqual(item, b[i]))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const aRec = a as Record<string, unknown>
|
|
218
|
+
const bRec = b as Record<string, unknown>
|
|
219
|
+
const aKeys = Object.keys(aRec)
|
|
220
|
+
const bKeys = Object.keys(bRec)
|
|
221
|
+
if (aKeys.length !== bKeys.length) return false
|
|
222
|
+
return aKeys.every((k) => Object.prototype.hasOwnProperty.call(bRec, k) && valuesEqual(aRec[k], bRec[k]))
|
|
223
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { PublishStatusData } from '../composables/usePublishStatus'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The handle `CollectionEditor` exposes via `defineExpose` — the header hosts (`[collection]/[id].vue`
|
|
5
|
+
* and `SingletonEditor.vue`) drive Save / undo / preview and read the status ampel through it. ONE typed
|
|
6
|
+
* contract instead of three drifting inline copies: Vue does not type-check a template `ref="editorRef"`
|
|
7
|
+
* against `defineExpose`, so without a shared interface a renamed/removed key (e.g. `previewUrl`) compiles
|
|
8
|
+
* in both hosts and silently becomes `undefined` at runtime. Type both host refs as `EditorExpose | null`.
|
|
9
|
+
*/
|
|
10
|
+
export interface EditorExpose {
|
|
11
|
+
dirty: boolean
|
|
12
|
+
saving: boolean
|
|
13
|
+
canUndo: boolean
|
|
14
|
+
canRedo: boolean
|
|
15
|
+
undo: () => void
|
|
16
|
+
redo: () => void
|
|
17
|
+
hasStatus: boolean
|
|
18
|
+
status: string
|
|
19
|
+
/** The last SAVED publish status (baseline), for the right lamp's draft/published decision. */
|
|
20
|
+
savedStatus: string
|
|
21
|
+
previewUrl: string | null
|
|
22
|
+
pageLike: boolean
|
|
23
|
+
live: PublishStatusData | null
|
|
24
|
+
/** The record's own title (see `recordTitle`), or `''` — the header then shows "Edit {collection} #{id}". */
|
|
25
|
+
recordTitle: string
|
|
26
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ComputedRef, InjectionKey, Ref } from 'vue'
|
|
2
|
+
import type { FieldDef } from '../../../core/server/utils/defineCollection'
|
|
3
|
+
import type { LayoutNode } from '../../../core/server/utils/field-layout'
|
|
4
|
+
import type { BlockErrorMap } from './edit-form'
|
|
5
|
+
import type { DeadRef } from './dead-refs'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The form context `CollectionEditor` (the generic shell) provides to whichever editor **body** the
|
|
9
|
+
* registry resolves (fields · blocks · an extension's, e.g. node-graph). It carries the load-bearing
|
|
10
|
+
* `useEditForm` surface a body needs to read and mutate the record, so a body swaps in without a giant
|
|
11
|
+
* prop list — the same provide/inject seam as `recordEditContextKey`, one level up.
|
|
12
|
+
*
|
|
13
|
+
* Provide it BEFORE the shell's top-level `await` (after an await the setup instance is gone and `provide`
|
|
14
|
+
* silently no-ops), exactly like the record-edit context.
|
|
15
|
+
*/
|
|
16
|
+
/** Props for the shared page-fields pane. Defined once here so the shell's pre-built bindings and
|
|
17
|
+
* `PageFieldsPane`'s own `defineProps` stay a single type (a widened `Record` would break the v-bind). */
|
|
18
|
+
export interface PageFieldsBindings {
|
|
19
|
+
translatable?: boolean
|
|
20
|
+
collection: string
|
|
21
|
+
id: string
|
|
22
|
+
mode: 'single' | 'multi'
|
|
23
|
+
locale: string
|
|
24
|
+
translations: Record<string, number | null>
|
|
25
|
+
group?: string
|
|
26
|
+
fields: Record<string, FieldDef>
|
|
27
|
+
/** Normalized admin editor layout for the page fields (undefined → one field per row). */
|
|
28
|
+
fieldLayout?: LayoutNode[]
|
|
29
|
+
values: Record<string, unknown>
|
|
30
|
+
errors: Record<string, string>
|
|
31
|
+
deadFields?: Set<string>
|
|
32
|
+
pageLike?: boolean
|
|
33
|
+
seo?: boolean
|
|
34
|
+
status?: boolean
|
|
35
|
+
disabled?: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface EditorFormContext {
|
|
39
|
+
/** Reactive form values / per-field errors (from `useEditForm`; reactive objects, not refs). */
|
|
40
|
+
values: Record<string, unknown>
|
|
41
|
+
errors: Record<string, string>
|
|
42
|
+
formError: Ref<string>
|
|
43
|
+
blockErrors: Ref<BlockErrorMap>
|
|
44
|
+
/** Route every mutation through this (→ block-error reconciliation, dirty tracking, history). The
|
|
45
|
+
* optional `coalesceAs` lets a caller (e.g. a block-tree structural op) tag its own undo-history key
|
|
46
|
+
* instead of coalescing under the bare field name. */
|
|
47
|
+
setField: (name: string, value: unknown, coalesceAs?: string) => void
|
|
48
|
+
locale: Ref<string>
|
|
49
|
+
translations: Ref<Record<string, number | null>>
|
|
50
|
+
translationGroup: Ref<string | undefined>
|
|
51
|
+
deadRefs: Ref<DeadRef[]>
|
|
52
|
+
saving: Ref<boolean>
|
|
53
|
+
mode: Ref<'multi' | 'single'>
|
|
54
|
+
translatable: Ref<boolean>
|
|
55
|
+
pageLike: Ref<boolean>
|
|
56
|
+
hasSeo: Ref<boolean>
|
|
57
|
+
hasStatus: Ref<boolean>
|
|
58
|
+
blocksAllowed: Ref<string[] | undefined>
|
|
59
|
+
/** Collection (page-root) fields as `FieldDef`s, ready for the field Renderer. */
|
|
60
|
+
renderable: ComputedRef<Record<string, FieldDef>>
|
|
61
|
+
/** The record's localized public URL (saved pageLike record with a path), or null (new/unsaved,
|
|
62
|
+
* non-pageLike). The shell computes it once for the header's open-in-new-tab AND the live preview. */
|
|
63
|
+
previewUrl: ComputedRef<string | null>
|
|
64
|
+
undo: () => void
|
|
65
|
+
redo: () => void
|
|
66
|
+
applyFrom: (row: Record<string, unknown>) => void
|
|
67
|
+
/** Pre-bound props/handlers for the shared page-fields pane, rendered by both the fields and blocks
|
|
68
|
+
* bodies (identical binding, differing only in layout chrome). */
|
|
69
|
+
pageFieldsBindings: ComputedRef<PageFieldsBindings>
|
|
70
|
+
pageFieldsHandlers: Record<string, (...args: never[]) => void>
|
|
71
|
+
/** A body registers how to reveal a validation failure (e.g. the blocks body focuses the offending
|
|
72
|
+
* block). The shell calls it on a failed save, staying agnostic of the body's internals. */
|
|
73
|
+
registerRevealError: (fn: () => void) => void
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const editorFormContextKey = Symbol('kestrel.editorForm') as InjectionKey<EditorFormContext>
|
|
77
|
+
|
|
78
|
+
/** Inject the editor form context inside a body component; throws if used outside `CollectionEditor`. */
|
|
79
|
+
export function useEditorFormContext(): EditorFormContext {
|
|
80
|
+
const ctx = inject(editorFormContextKey)
|
|
81
|
+
if (!ctx) throw new Error('[kestrel] editor body used outside CollectionEditor (no editor form context)')
|
|
82
|
+
return ctx
|
|
83
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Component } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Registry of admin editor *bodies* keyed by editor type (the `editor` field on a serialized collection).
|
|
5
|
+
* Mirrors the field-widget registry (`layers/ui/app/utils/field-registry.ts`), one level up: instead of
|
|
6
|
+
* picking a widget per field type, it picks the whole editor body per collection.
|
|
7
|
+
*
|
|
8
|
+
* The built-in `fields` / `blocks` bodies register themselves when the admin editor loads (see
|
|
9
|
+
* `register-editors`); extensions add their own via a `*.client.ts` plugin (auto-discovered through
|
|
10
|
+
* `extends`), exactly like `registerFieldComponent`. The frame — the admin rail (layout) and the record
|
|
11
|
+
* header/toolbar + form state (`CollectionEditor` / the host pages) — stays generic; only this body swaps.
|
|
12
|
+
*/
|
|
13
|
+
export const editorComponents: Record<string, Component> = {}
|
|
14
|
+
|
|
15
|
+
export const resolveCollectionEditor = (type: string): Component | undefined => editorComponents[type]
|
|
16
|
+
|
|
17
|
+
/** Register an editor body for a type. Mutates the singleton; a later call overrides the same type. */
|
|
18
|
+
export function registerCollectionEditor(type: string, component: Component): void {
|
|
19
|
+
editorComponents[type] = component
|
|
20
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SerializedField } from '../../../core/server/utils/serialize-collection'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The jsKey (wire key) for a field — NOT a DB column name. The server marks single relation/media fields
|
|
5
|
+
* with `single` (stored as a `<name>Id` FK column, e.g. `author` -> `authorId`); everything else keeps the
|
|
6
|
+
* field name. The editor binds widgets by field name but must read/write rows, request bodies and list
|
|
7
|
+
* queries by this key — reading the server flag instead of re-deriving the rule (which silently drifts).
|
|
8
|
+
*/
|
|
9
|
+
export function jsKey(name: string, field: SerializedField): string {
|
|
10
|
+
return field.single ? `${name}Id` : name
|
|
11
|
+
}
|