@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,370 @@
|
|
|
1
|
+
import type { SerializedField } from '../../../core/server/utils/serialize-collection'
|
|
2
|
+
import type { LayoutNode } from '../../../core/server/utils/field-layout'
|
|
3
|
+
import type { BlockErrorMap } from '../utils/edit-form'
|
|
4
|
+
import type { DeadRef } from '../utils/dead-refs'
|
|
5
|
+
import { asFieldDef, initialValues, mapServerErrors, parseBlockErrors, reconcileBlockErrors, readFetchError } from '../utils/edit-form'
|
|
6
|
+
|
|
7
|
+
export interface UseEditFormOptions {
|
|
8
|
+
collection: string
|
|
9
|
+
id: string
|
|
10
|
+
/** Requested content locale (from the `?locale` route query). Defaults to the primary locale. */
|
|
11
|
+
locale?: string
|
|
12
|
+
/** translationGroup to link a NEW multi translation to its siblings (from the `?group` query). */
|
|
13
|
+
group?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type SubmitResult = { ok: true; record: unknown } | { ok: false }
|
|
17
|
+
|
|
18
|
+
function snapshot(values: Record<string, unknown>): Record<string, unknown> {
|
|
19
|
+
return JSON.parse(JSON.stringify(values))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useEditForm(opts: UseEditFormOptions) {
|
|
23
|
+
const { collection, id } = opts
|
|
24
|
+
const { t } = useT()
|
|
25
|
+
const { primary } = useContentLocales()
|
|
26
|
+
// The locale the editor *requests* (single GET/PUT + a new multi translation). For an existing multi
|
|
27
|
+
// row the active locale comes from the row itself (set in init); it can't be switched via a query.
|
|
28
|
+
const requestedLocale = opts.locale?.trim() || primary
|
|
29
|
+
const activeLocale = ref(requestedLocale)
|
|
30
|
+
const translations = ref<Record<string, number | null>>({})
|
|
31
|
+
// Stale references this record currently holds (derived on read) — drives the editor's dead-ref warnings.
|
|
32
|
+
const deadRefs = ref<DeadRef[]>([])
|
|
33
|
+
// The translation group the LocaleBar links new siblings to: the requested group for a new
|
|
34
|
+
// translation, or the loaded row's own group for an existing multi record.
|
|
35
|
+
const translationGroup = ref<string | undefined>(opts.group?.trim() || undefined)
|
|
36
|
+
|
|
37
|
+
const fields = ref<Record<string, SerializedField>>({})
|
|
38
|
+
// The collection's normalized admin editor layout (resolved server-side), or undefined → one field per row.
|
|
39
|
+
const fieldLayout = ref<LayoutNode[] | undefined>(undefined)
|
|
40
|
+
const mode = ref<'multi' | 'single'>('multi')
|
|
41
|
+
const translatable = ref(false)
|
|
42
|
+
const blocksEnabled = ref(false)
|
|
43
|
+
const blocksAllowed = ref<string[] | undefined>(undefined)
|
|
44
|
+
// pageLike collections carry a routable `path` (the slug) as a system column, edited in the page editor.
|
|
45
|
+
const pageLike = ref(false)
|
|
46
|
+
// seo-enabled collections carry a `seo` JSON system column ({ title, description, noindex }).
|
|
47
|
+
const hasSeo = ref(false)
|
|
48
|
+
// status-enabled collections carry a `status` system column ('draft' | 'published') — the publish gate.
|
|
49
|
+
const hasStatus = ref(false)
|
|
50
|
+
// Which editor body renders (the `editor` type on the serialized collection). The server resolves it,
|
|
51
|
+
// but fall back defensively (blocks → 'blocks', else 'fields') so an older/partial schema still works.
|
|
52
|
+
const editorType = ref('fields')
|
|
53
|
+
|
|
54
|
+
const values = reactive<Record<string, unknown>>({})
|
|
55
|
+
const errors = reactive<Record<string, string>>({})
|
|
56
|
+
const formError = ref('')
|
|
57
|
+
const blockErrors = ref<BlockErrorMap>({})
|
|
58
|
+
const saving = ref(false)
|
|
59
|
+
const baseline = ref<Record<string, unknown>>({})
|
|
60
|
+
const baseUpdatedAt = ref<number | undefined>(undefined)
|
|
61
|
+
|
|
62
|
+
const dirty = computed(() => !valuesEqual(values, baseline.value))
|
|
63
|
+
// The LAST SAVED publish status (from the baseline snapshot, not the live edited value). The editor's
|
|
64
|
+
// right "live" lamp keys its draft/published decision off this — an UNSAVED dropdown switch must not flip
|
|
65
|
+
// the generated-state indicator (nothing is generated until the change is saved). '' when statusless.
|
|
66
|
+
const savedStatus = computed(() => (hasStatus.value ? ((baseline.value.status as string | undefined) ?? '') : ''))
|
|
67
|
+
// The LAST SAVED page path (baseline, not the live edited slug). The preview iframe + the header's
|
|
68
|
+
// open-in-new-tab must target a URL that actually EXISTS — an unsaved slug edit would 404 until saved;
|
|
69
|
+
// on save the baseline moves and both follow to the new URL. '' when non-pageLike or never saved.
|
|
70
|
+
const savedPath = computed(() => (pageLike.value ? ((baseline.value.path as string | undefined) ?? '') : ''))
|
|
71
|
+
|
|
72
|
+
// Undo/redo: snapshot history of the whole form state. Every edit funnels through setField (block-tree
|
|
73
|
+
// ops route through setField('content', …)), so that's the single capture point. A rapid burst of edits
|
|
74
|
+
// to the same field coalesces into one step; the stacks reset on load/save (no undo across a save).
|
|
75
|
+
const past = ref<Record<string, unknown>[]>([])
|
|
76
|
+
const future = ref<Record<string, unknown>[]>([])
|
|
77
|
+
const canUndo = computed(() => past.value.length > 0)
|
|
78
|
+
const canRedo = computed(() => future.value.length > 0)
|
|
79
|
+
let coalesceKey = ''
|
|
80
|
+
let coalesceAt = 0
|
|
81
|
+
const COALESCE_MS = 600
|
|
82
|
+
const HISTORY_LIMIT = 100
|
|
83
|
+
|
|
84
|
+
function recordHistory(name: string) {
|
|
85
|
+
const now = Date.now()
|
|
86
|
+
const coalesce = name === coalesceKey && now - coalesceAt < COALESCE_MS
|
|
87
|
+
coalesceKey = name
|
|
88
|
+
coalesceAt = now
|
|
89
|
+
if (coalesce) return
|
|
90
|
+
past.value.push(snapshot(values))
|
|
91
|
+
if (past.value.length > HISTORY_LIMIT) past.value.shift()
|
|
92
|
+
future.value = []
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function restore(snap: Record<string, unknown>) {
|
|
96
|
+
const fresh = snapshot(snap)
|
|
97
|
+
for (const k of Object.keys(fresh)) values[k] = fresh[k]
|
|
98
|
+
for (const k of Object.keys(values)) if (!(k in fresh)) delete values[k]
|
|
99
|
+
for (const k of Object.keys(errors)) delete errors[k]
|
|
100
|
+
blockErrors.value = {}
|
|
101
|
+
formError.value = ''
|
|
102
|
+
coalesceKey = ''
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function undo() {
|
|
106
|
+
if (!past.value.length) return
|
|
107
|
+
future.value.push(snapshot(values))
|
|
108
|
+
restore(past.value.pop()!)
|
|
109
|
+
}
|
|
110
|
+
function redo() {
|
|
111
|
+
if (!future.value.length) return
|
|
112
|
+
past.value.push(snapshot(values))
|
|
113
|
+
restore(future.value.pop()!)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const renderableFields = computed<Record<string, SerializedField>>(() => {
|
|
117
|
+
if (!blocksEnabled.value) return fields.value
|
|
118
|
+
const { content: _content, ...rest } = fields.value
|
|
119
|
+
return rest
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
function fieldKeys() {
|
|
123
|
+
return Object.keys(fields.value)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function rebaseline(source: Record<string, unknown> | null) {
|
|
127
|
+
const next = initialValues(fields.value)
|
|
128
|
+
if (source) {
|
|
129
|
+
for (const k of fieldKeys()) {
|
|
130
|
+
const col = jsKey(k, fields.value[k]!)
|
|
131
|
+
if (col in source) next[k] = source[col]
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// `content` is a synthesized blocks column, not a def.field — round-trip it explicitly.
|
|
135
|
+
if (blocksEnabled.value) next.content = (source?.content as unknown[]) ?? []
|
|
136
|
+
// `path` (the page slug) is a pageLike system column, likewise round-tripped explicitly.
|
|
137
|
+
if (pageLike.value) next.path = (source?.path as string | null | undefined) ?? ''
|
|
138
|
+
// `seo` is a JSON system column; default to an empty object so the editor can fill it in.
|
|
139
|
+
if (hasSeo.value) next.seo = (source?.seo as Record<string, unknown> | undefined) ?? {}
|
|
140
|
+
// `status` is a system column; a new record defaults to 'draft' (unpublished) — matches the DB default.
|
|
141
|
+
if (hasStatus.value) next.status = (source?.status as string | undefined) ?? 'draft'
|
|
142
|
+
for (const k of Object.keys(next)) values[k] = next[k]
|
|
143
|
+
baseline.value = snapshot(values)
|
|
144
|
+
// The optimistic-concurrency baseline: the `updatedAt` this load/save established, sent back on the
|
|
145
|
+
// next save so a stale tab (loaded before someone else saved) is refused with 409 instead of
|
|
146
|
+
// silently reverting their change. undefined for a never-saved new record.
|
|
147
|
+
const stamp = source?.updatedAt
|
|
148
|
+
baseUpdatedAt.value = stamp != null ? new Date(stamp as string | number).getTime() : undefined
|
|
149
|
+
// A fresh load or a save establishes a new baseline — history does not span it.
|
|
150
|
+
past.value = []
|
|
151
|
+
future.value = []
|
|
152
|
+
coalesceKey = ''
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function init() {
|
|
156
|
+
const all = await useCollections().load()
|
|
157
|
+
const schema = all.find((c) => c.name === collection)
|
|
158
|
+
if (!schema) throw createError({ statusCode: 404, statusMessage: `Unknown collection: ${collection}` })
|
|
159
|
+
fields.value = schema.fields
|
|
160
|
+
fieldLayout.value = schema.fieldLayout
|
|
161
|
+
mode.value = schema.mode
|
|
162
|
+
translatable.value = schema.translatable
|
|
163
|
+
blocksEnabled.value = schema.blocks?.enabled ?? false
|
|
164
|
+
blocksAllowed.value = schema.blocks?.allowed
|
|
165
|
+
pageLike.value = schema.pageLike ?? false
|
|
166
|
+
hasSeo.value = schema.seo ?? false
|
|
167
|
+
hasStatus.value = schema.status ?? false
|
|
168
|
+
editorType.value = (schema as { editor?: string }).editor ?? (schema.blocks?.enabled ? 'blocks' : 'fields')
|
|
169
|
+
|
|
170
|
+
let row: Record<string, unknown> | null = null
|
|
171
|
+
if (mode.value === 'single') {
|
|
172
|
+
row = await $fetch<Record<string, unknown> | null>(`/api/${collection}`, translatable.value ? { query: { locale: requestedLocale } } : {})
|
|
173
|
+
activeLocale.value = requestedLocale
|
|
174
|
+
} else if (id !== 'new') {
|
|
175
|
+
row = await $fetch<Record<string, unknown>>(`/api/${collection}/${id}`)
|
|
176
|
+
if (translatable.value) {
|
|
177
|
+
if (typeof row?.locale === 'string') activeLocale.value = row.locale
|
|
178
|
+
if (typeof row?.translationGroup === 'string') translationGroup.value = row.translationGroup
|
|
179
|
+
// Supplementary (drives the LocaleBar) — never block editing if it fails.
|
|
180
|
+
try {
|
|
181
|
+
translations.value = await $fetch<Record<string, number | null>>(`/api/${collection}/${id}/translations`)
|
|
182
|
+
} catch {
|
|
183
|
+
translations.value = {}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
activeLocale.value = requestedLocale
|
|
188
|
+
// A new sibling translation has no id yet, so its locale→id map is resolved by GROUP. Without it the
|
|
189
|
+
// LocaleBar shows every already-translated locale as a "+" create link: the copy-from source is
|
|
190
|
+
// unreachable, and following that link saves a second row for an occupied locale — a duplicate-locale
|
|
191
|
+
// 409 the user can't resolve. Supplementary (drives the LocaleBar) — never block editing if it fails.
|
|
192
|
+
if (translatable.value && translationGroup.value) {
|
|
193
|
+
try {
|
|
194
|
+
translations.value = await $fetch<Record<string, number | null>>(`/api/${collection}/translations`, { query: { group: translationGroup.value } })
|
|
195
|
+
} catch {
|
|
196
|
+
translations.value = {}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
rebaseline(row)
|
|
201
|
+
|
|
202
|
+
// Supplementary dead-reference map (the editor warnings) — for any saved record, never blocks editing.
|
|
203
|
+
const recordId = typeof row?.id === 'number' ? row.id : null
|
|
204
|
+
if (recordId != null) {
|
|
205
|
+
try {
|
|
206
|
+
deadRefs.value = await $fetch<DeadRef[]>(`/api/${collection}/${recordId}/dead-refs`)
|
|
207
|
+
} catch {
|
|
208
|
+
deadRefs.value = []
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
deadRefs.value = [] // a new (unsaved) record has no references — keep it explicit, never stale
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// `coalesceAs` lets a caller opt out of the default same-name coalescing: block-tree structural ops
|
|
216
|
+
// (remove/move/duplicate/add) all round-trip through setField('content', …) like a text edit does, so
|
|
217
|
+
// without a distinct key a delete landing inside another edit's 600ms burst would merge into it and
|
|
218
|
+
// become impossible to undo on its own.
|
|
219
|
+
function setField(name: string, value: unknown, coalesceAs: string = name) {
|
|
220
|
+
// Capture the pre-edit state for undo (coalesced for a same-field typing burst).
|
|
221
|
+
recordHistory(coalesceAs)
|
|
222
|
+
// Reconcile before overwriting: keep block errors through a reorder, drop them on an edit.
|
|
223
|
+
if (name === 'content') {
|
|
224
|
+
blockErrors.value = reconcileBlockErrors(blockErrors.value, values.content as unknown[], value as unknown[])
|
|
225
|
+
}
|
|
226
|
+
values[name] = value
|
|
227
|
+
formError.value = ''
|
|
228
|
+
const field = fields.value[name]
|
|
229
|
+
if (field) errors[name] = isFieldVisible(field, values) ? (validateField(asFieldDef(field), value) ?? '') : ''
|
|
230
|
+
// A controller edit can hide sibling fields — drop their now-irrelevant advisory errors so a
|
|
231
|
+
// hidden required field never shows a phantom error or blocks the save.
|
|
232
|
+
for (const [n, f] of Object.entries(fields.value)) if (!isFieldVisible(f, values)) errors[n] = ''
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Copy another locale's field/content values into this form (the LocaleBar "copy" action). Goes
|
|
236
|
+
* through setField so validation, block-error reconcile, and dirty-tracking all apply. */
|
|
237
|
+
function applyFrom(source: Record<string, unknown>) {
|
|
238
|
+
for (const k of fieldKeys()) {
|
|
239
|
+
const col = jsKey(k, fields.value[k]!)
|
|
240
|
+
if (col in source) setField(k, source[col])
|
|
241
|
+
}
|
|
242
|
+
if (blocksEnabled.value && 'content' in source) setField('content', source.content)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function validateAll(): boolean {
|
|
246
|
+
let ok = true
|
|
247
|
+
for (const [name, field] of Object.entries(fields.value)) {
|
|
248
|
+
// A hidden field is exempt from validation (its `required` only applies when shown) — mirrors
|
|
249
|
+
// the server's required-when-visible enforcement, so the client never blocks a valid save.
|
|
250
|
+
if (!isFieldVisible(field, values)) { errors[name] = ''; continue }
|
|
251
|
+
const msg = validateField(asFieldDef(field), values[name])
|
|
252
|
+
errors[name] = msg ?? ''
|
|
253
|
+
if (msg) ok = false
|
|
254
|
+
}
|
|
255
|
+
return ok
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function buildBody(): Record<string, unknown> {
|
|
259
|
+
const body: Record<string, unknown> = {}
|
|
260
|
+
for (const k of fieldKeys()) body[jsKey(k, fields.value[k]!)] = values[k]
|
|
261
|
+
if (blocksEnabled.value) body.content = values.content
|
|
262
|
+
// Send the slug as the routable path; a blank slug clears the route (stored as null, not "").
|
|
263
|
+
if (pageLike.value) body.path = (values.path as string) ? values.path : null
|
|
264
|
+
if (hasSeo.value) body.seo = values.seo ?? {}
|
|
265
|
+
if (hasStatus.value) body.status = (values.status as string) ?? 'draft'
|
|
266
|
+
// A new translatable multi record carries its locale, and links to a translation group when it is
|
|
267
|
+
// a sibling translation. Existing rows keep their locale; the singleton uses the ?locale query.
|
|
268
|
+
if (translatable.value && mode.value === 'multi' && id === 'new') {
|
|
269
|
+
body.locale = activeLocale.value
|
|
270
|
+
if (translationGroup.value) body.translationGroup = translationGroup.value
|
|
271
|
+
}
|
|
272
|
+
return body
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function handleError(e: unknown) {
|
|
276
|
+
const { statusCode, statusMessage, issues } = readFetchError(e)
|
|
277
|
+
if (statusCode === 400) {
|
|
278
|
+
const mapped = mapServerErrors(issues)
|
|
279
|
+
for (const [k, msg] of Object.entries(mapped.fields)) errors[k] = msg
|
|
280
|
+
// `content` errors are nested (content[i].props.x) and have no flat field to render them,
|
|
281
|
+
// so surface a form-level banner instead of failing silently.
|
|
282
|
+
blockErrors.value = parseBlockErrors(issues, values.content as unknown[])
|
|
283
|
+
// A field error (e.g. the page slug) renders inline in PageFields, but that pane is unmounted while a
|
|
284
|
+
// block is selected — so also raise a banner whenever there are field errors, never a silent failure.
|
|
285
|
+
formError.value = mapped.form
|
|
286
|
+
?? (mapped.fields.content
|
|
287
|
+
? t('editor.fixBlockContent')
|
|
288
|
+
: Object.keys(mapped.fields).length ? t('editor.fixPageFields') : '')
|
|
289
|
+
} else if (statusCode === 409) {
|
|
290
|
+
formError.value = statusMessage ?? t('editor.saveConflict')
|
|
291
|
+
} else {
|
|
292
|
+
formError.value = statusMessage ?? t('editor.saveFailed')
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function submit(): Promise<SubmitResult> {
|
|
297
|
+
formError.value = ''
|
|
298
|
+
blockErrors.value = {}
|
|
299
|
+
// Clear the whole map, not just declared-field errors: `path`/`seo`/`status` are system columns
|
|
300
|
+
// (never in `fields.value`), so a stale server error on one of them would otherwise survive a
|
|
301
|
+
// later successful save. `validateAll` below repopulates every declared-field error fresh.
|
|
302
|
+
for (const k of Object.keys(errors)) delete errors[k]
|
|
303
|
+
// Client-side validation failed: surface the same banner the server path raises, so the failure is
|
|
304
|
+
// never silent — the offending field's inline error can sit in an unmounted pane (blocks editor).
|
|
305
|
+
if (!validateAll()) {
|
|
306
|
+
formError.value = t('editor.fixPageFields')
|
|
307
|
+
return { ok: false }
|
|
308
|
+
}
|
|
309
|
+
saving.value = true
|
|
310
|
+
try {
|
|
311
|
+
const body = buildBody()
|
|
312
|
+
// The optimistic-concurrency precondition: send the baseline `updatedAt` so a stale overwrite 409s
|
|
313
|
+
// instead of silently reverting a concurrent save. Omitted for a brand-new record (no baseline).
|
|
314
|
+
const ifUnmodified: Record<string, string> = baseUpdatedAt.value !== undefined ? { 'x-kestrel-if-unmodified-since': String(baseUpdatedAt.value) } : {}
|
|
315
|
+
// `method` cast: Nuxt's typed `$fetch` over-constrains the method for this dynamic (template-literal)
|
|
316
|
+
// admin route to GET; the runtime value is correct, only the static route type can't express it.
|
|
317
|
+
const row = mode.value === 'single'
|
|
318
|
+
? await $fetch(`/api/${collection}`, { method: 'PUT' as never, body, headers: ifUnmodified, ...(translatable.value ? { query: { locale: activeLocale.value } } : {}) })
|
|
319
|
+
: id === 'new'
|
|
320
|
+
? await $fetch(`/api/${collection}`, { method: 'POST' as never, body })
|
|
321
|
+
: await $fetch(`/api/${collection}/${id}`, { method: 'PATCH', body, headers: ifUnmodified })
|
|
322
|
+
rebaseline(row as Record<string, unknown>)
|
|
323
|
+
// `record` is the saved entity at the editor's public boundary; `row` stays the DB-layer term.
|
|
324
|
+
return { ok: true, record: row }
|
|
325
|
+
} catch (e) {
|
|
326
|
+
handleError(e)
|
|
327
|
+
return { ok: false }
|
|
328
|
+
} finally {
|
|
329
|
+
saving.value = false
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const ready = init()
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
collection,
|
|
337
|
+
locale: activeLocale,
|
|
338
|
+
translations,
|
|
339
|
+
deadRefs,
|
|
340
|
+
translationGroup,
|
|
341
|
+
applyFrom,
|
|
342
|
+
mode,
|
|
343
|
+
fields,
|
|
344
|
+
fieldLayout,
|
|
345
|
+
renderableFields,
|
|
346
|
+
translatable,
|
|
347
|
+
blocksEnabled,
|
|
348
|
+
blocksAllowed,
|
|
349
|
+
editorType,
|
|
350
|
+
pageLike,
|
|
351
|
+
hasSeo,
|
|
352
|
+
hasStatus,
|
|
353
|
+
values,
|
|
354
|
+
errors,
|
|
355
|
+
formError,
|
|
356
|
+
blockErrors,
|
|
357
|
+
dirty,
|
|
358
|
+
savedStatus,
|
|
359
|
+
savedPath,
|
|
360
|
+
saving,
|
|
361
|
+
ready,
|
|
362
|
+
setField,
|
|
363
|
+
validateAll,
|
|
364
|
+
submit,
|
|
365
|
+
undo,
|
|
366
|
+
redo,
|
|
367
|
+
canUndo,
|
|
368
|
+
canRedo,
|
|
369
|
+
}
|
|
370
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { reactive } from 'vue'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Batched, cached client resolution of internal-link refs (`{collection, id}`) → public href,
|
|
5
|
+
* mirroring the server `resolveInternalHref` so the live preview's `KestrelLink` renders real internal
|
|
6
|
+
* paths instead of `'#'`. Per instance (no shared module state → SSR-safe). No locale param: an
|
|
7
|
+
* internal link points to one specific record, whose href is its own locale's path. Misses are cached
|
|
8
|
+
* (empty string) so a dangling (missing-target) ref isn't re-fetched on every preview recompute.
|
|
9
|
+
*/
|
|
10
|
+
export function useLinkResolver() {
|
|
11
|
+
const cache = reactive(new Map<string, string>())
|
|
12
|
+
const pending = new Set<string>()
|
|
13
|
+
const keyFor = (collection: string, id: number) => `${collection}:${id}`
|
|
14
|
+
|
|
15
|
+
async function ensure(refs: { collection: string; id: number }[]): Promise<void> {
|
|
16
|
+
const missing = refs.filter((r) =>
|
|
17
|
+
!!r.collection && Number.isInteger(r.id) && r.id > 0
|
|
18
|
+
&& !cache.has(keyFor(r.collection, r.id)) && !pending.has(keyFor(r.collection, r.id)))
|
|
19
|
+
if (!missing.length) return
|
|
20
|
+
const keys = missing.map((r) => keyFor(r.collection, r.id))
|
|
21
|
+
for (const k of keys) pending.add(k)
|
|
22
|
+
try {
|
|
23
|
+
const { data } = await $fetch<{ data: { collection: string; id: number; href: string }[] }>(
|
|
24
|
+
'/api/links/resolve', { query: { refs: keys.join(',') } },
|
|
25
|
+
)
|
|
26
|
+
const got = new Map(data.map((d) => [keyFor(d.collection, d.id), d.href]))
|
|
27
|
+
for (const k of keys) cache.set(k, got.get(k) ?? '') // cache misses too, as '' → no re-fetch
|
|
28
|
+
} catch {
|
|
29
|
+
// leave unresolved — the preview shows '#' for these until a later attempt
|
|
30
|
+
} finally {
|
|
31
|
+
for (const k of keys) pending.delete(k)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolve(collection: string, id: number): string | null {
|
|
36
|
+
return cache.get(keyFor(collection, id)) || null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return { cache, ensure, resolve }
|
|
40
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Ref } from 'vue'
|
|
2
|
+
import type { SerializedCollection } from '../../../core/server/utils/serialize-collection'
|
|
3
|
+
import { availableColumns, defaultVisibleKeys, resolveVisibleColumns, type ListColumn } from '../utils/list-columns'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Per-collection list column visibility, persisted in a cookie (SSR-safe, like the rail-collapsed state).
|
|
7
|
+
* The store maps collection name → the chosen visible keys; an absent entry falls back to the default set.
|
|
8
|
+
* Stale keys (a since-removed field) are dropped on read via `resolveVisibleColumns`.
|
|
9
|
+
*/
|
|
10
|
+
export function useListColumns(schema: Ref<SerializedCollection>) {
|
|
11
|
+
const store = useCookie<Record<string, string[]>>('kestrel-list-columns', { default: () => ({}) })
|
|
12
|
+
|
|
13
|
+
const available = computed<ListColumn[]>(() => availableColumns(schema.value))
|
|
14
|
+
const defaults = computed<string[]>(() => defaultVisibleKeys(schema.value))
|
|
15
|
+
|
|
16
|
+
const visibleKeys = computed<string[]>(() => {
|
|
17
|
+
const stored = store.value[schema.value.name]
|
|
18
|
+
// Project through the available columns so order is canonical and removed columns disappear.
|
|
19
|
+
return resolveVisibleColumns(available.value, stored ?? defaults.value).map((c) => c.key)
|
|
20
|
+
})
|
|
21
|
+
const visibleColumns = computed<ListColumn[]>(() => resolveVisibleColumns(available.value, visibleKeys.value))
|
|
22
|
+
|
|
23
|
+
function persist(keys: string[]) {
|
|
24
|
+
// Keep canonical order so persisted prefs round-trip stably.
|
|
25
|
+
const ordered = available.value.filter((c) => keys.includes(c.key)).map((c) => c.key)
|
|
26
|
+
store.value = { ...store.value, [schema.value.name]: ordered }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function toggle(key: string) {
|
|
30
|
+
const next = new Set(visibleKeys.value)
|
|
31
|
+
if (next.has(key)) {
|
|
32
|
+
if (next.size <= 1) return // never hide the last column
|
|
33
|
+
next.delete(key)
|
|
34
|
+
} else {
|
|
35
|
+
next.add(key)
|
|
36
|
+
}
|
|
37
|
+
persist([...next])
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function reset() {
|
|
41
|
+
const next = { ...store.value }
|
|
42
|
+
delete next[schema.value.name]
|
|
43
|
+
store.value = next
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const isDefault = computed(() => {
|
|
47
|
+
const a = visibleKeys.value
|
|
48
|
+
const b = defaults.value
|
|
49
|
+
return a.length === b.length && a.every((k, i) => k === b[i])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
return { available, visibleColumns, visibleKeys, toggle, reset, isDefault }
|
|
53
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Ref } from 'vue'
|
|
2
|
+
import type { ListColumn } from '../utils/list-columns'
|
|
3
|
+
import { toQuery, toggleSort, parseListQuery, type FilterCell, type ListState } from '../utils/list-query'
|
|
4
|
+
import { clampPerPage, DEFAULT_PER_PAGE } from '../../../core/app/utils/list-limits'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The URL is the SINGLE SOURCE OF TRUTH for a collection list's committed state (sort · page · perPage ·
|
|
8
|
+
* filter). This composable derives that state from `route.query` (sanitized via `parseListQuery`, so a
|
|
9
|
+
* hostile/partial URL degrades to defaults rather than reaching the server), and turns each mutation into a
|
|
10
|
+
* router navigation: `page` uses `push` (Back walks pages), everything else uses `replace` (typing / toggling
|
|
11
|
+
* must not spam history). Every non-page action also resets `page → 1`, so a page-only transition is the only
|
|
12
|
+
* one that ever differs by page alone — the push/replace split is exact.
|
|
13
|
+
*
|
|
14
|
+
* The per-page cookie is a DEFAULT, never state: when the URL carries no `perPage`, the initial value is
|
|
15
|
+
* seeded from the cookie (clamped); when perPage changes we write the cookie so a chosen density survives
|
|
16
|
+
* navigation. The URL always wins.
|
|
17
|
+
*/
|
|
18
|
+
export function useListUrlState(columns: Ref<ListColumn[]>) {
|
|
19
|
+
const route = useRoute()
|
|
20
|
+
const router = useRouter()
|
|
21
|
+
const perPageCookie = useCookie<number>('kestrel-list-per-page', { default: () => DEFAULT_PER_PAGE })
|
|
22
|
+
|
|
23
|
+
const parsed = computed(() => parseListQuery(route.query, columns.value))
|
|
24
|
+
const sort = computed(() => parsed.value.sort ?? '-createdAt')
|
|
25
|
+
const page = computed(() => parsed.value.page ?? 1)
|
|
26
|
+
const perPage = computed(() => parsed.value.perPage ?? clampPerPage(perPageCookie.value)) // URL wins, else cookie
|
|
27
|
+
const filter = computed(() => parsed.value.filter ?? {})
|
|
28
|
+
// The exact `$fetch` query the list endpoint expects — always the SANITIZED computeds, never the raw URL.
|
|
29
|
+
const effectiveQuery = computed(() => toQuery({ sort: sort.value, page: page.value, perPage: perPage.value, filter: filter.value }))
|
|
30
|
+
|
|
31
|
+
// We replace the WHOLE query on every navigation, so re-merge `locale` from the RAW URL (it lives outside
|
|
32
|
+
// the list-state keys and must survive a sort/page/filter change). Reading `route.query.locale` — NOT a
|
|
33
|
+
// resolved prop — keeps a locale-LESS URL locale-less after a list mutation: for a translatable collection
|
|
34
|
+
// the prop is the PRIMARY locale even when the URL carries no `?locale`, so merging it would inject
|
|
35
|
+
// `locale=<primary>`, flip the host page's `${collection}::${locale}` key and needlessly REMOUNT the list
|
|
36
|
+
// mid-edit. The locale switcher still navigates with an explicit `?locale`, which is preserved here (and
|
|
37
|
+
// correctly re-keys → remounts on a real locale switch).
|
|
38
|
+
function go(patch: Partial<ListState>, mode: 'push' | 'replace') {
|
|
39
|
+
const next: ListState = { sort: sort.value, page: page.value, perPage: perPage.value, filter: filter.value, ...patch }
|
|
40
|
+
const locale = route.query.locale
|
|
41
|
+
return router[mode]({ query: { ...toQuery(next), ...(typeof locale === 'string' ? { locale } : {}) } })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const setSort = (f: string) => go({ sort: toggleSort(sort.value, f), page: 1 }, 'replace')
|
|
45
|
+
const setPage = (p: number) => go({ page: p }, 'push')
|
|
46
|
+
// Corrective clamp (a page that emptied out from under us) — REPLACE, not push, so the now-invalid page
|
|
47
|
+
// doesn't stay a reachable history entry that Back bounces the user forward from on every press.
|
|
48
|
+
const clampPage = (p: number) => go({ page: p }, 'replace')
|
|
49
|
+
const setPerPage = (n: number) => { perPageCookie.value = clampPerPage(n); return go({ perPage: clampPerPage(n), page: 1 }, 'replace') }
|
|
50
|
+
const setFilter = (f: Record<string, FilterCell>) => go({ filter: f, page: 1 }, 'replace')
|
|
51
|
+
|
|
52
|
+
return { sort, page, perPage, filter, effectiveQuery, setSort, setPage, clampPage, setPerPage, setFilter }
|
|
53
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
// A create saves, then the host navigates the editor to the new record's own URL (`/new` → `/<id>`), which
|
|
4
|
+
// tears down the editor that issued the save and remounts a fresh one at the real id. The republish it
|
|
5
|
+
// kicked off is still in flight, so the ARRIVING editor must POLL the live status (not do a single refresh)
|
|
6
|
+
// to catch the "Generating…" → "Live" transition. This module-scoped flag carries that intent across the
|
|
7
|
+
// remount: the departing instance sets it on save, the arriving instance reads-and-clears it on mount.
|
|
8
|
+
// Client-only ephemeral signal (set in a user save handler, read on mount) — a plain ref is enough; it never
|
|
9
|
+
// runs during SSR, so there is no cross-request leakage.
|
|
10
|
+
const pending = ref(false)
|
|
11
|
+
|
|
12
|
+
/** Shared "the next editor mount should poll the live status" flag — see the note above. */
|
|
13
|
+
export function usePendingPublishPoll() {
|
|
14
|
+
return pending
|
|
15
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { computed } from 'vue'
|
|
2
|
+
import type { Dim } from '../utils/preview-viewport'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Persisted page-builder preview viewport: the target width/height as `Dim`s (a fixed px, or `'auto'`
|
|
6
|
+
* which fills the pane). Cookie-backed so the choice survives reloads and stays SSR-safe (server + client
|
|
7
|
+
* read the same value — no hydration mismatch), the same pattern as `useRailCollapsed`. Defaults to the
|
|
8
|
+
* config-driven desktop width with an `'auto'` height (fills the pane, no container scroll).
|
|
9
|
+
*
|
|
10
|
+
* Each setter reassigns the whole object (rather than mutating a field) so Nuxt's cookie watcher always
|
|
11
|
+
* re-serialises. Note: the seeded default only applies when the cookie is absent — if a project later
|
|
12
|
+
* changes `preview.desktopWidth`, an existing cookie keeps its `w` until the Desktop preset is clicked. A
|
|
13
|
+
* stale cookie from before Auto height (a numeric `h`, plus a now-ignored `fit`) is tolerated: it just
|
|
14
|
+
* reads as a fixed custom height until Desktop is re-selected.
|
|
15
|
+
*/
|
|
16
|
+
export interface PreviewViewport {
|
|
17
|
+
w: Dim
|
|
18
|
+
h: Dim
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function usePreviewViewport(desktopWidth: number) {
|
|
22
|
+
const state = useCookie<PreviewViewport>('kestrel-preview-viewport', {
|
|
23
|
+
default: () => ({ w: desktopWidth, h: 'auto' }),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const width = computed({ get: () => state.value.w, set: (v: Dim) => { state.value = { ...state.value, w: v } } })
|
|
27
|
+
const height = computed({ get: () => state.value.h, set: (v: Dim) => { state.value = { ...state.value, h: v } } })
|
|
28
|
+
|
|
29
|
+
return { width, height }
|
|
30
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { ref, toValue } from 'vue'
|
|
2
|
+
import type { MaybeRefOrGetter } from 'vue'
|
|
3
|
+
|
|
4
|
+
/** The live publish state of a record's static page, as returned by `GET /api/publish-status`. */
|
|
5
|
+
export interface PublishStatusData {
|
|
6
|
+
route: string | null
|
|
7
|
+
status: 'success' | 'error' | null
|
|
8
|
+
error?: string | null
|
|
9
|
+
updatedAt?: string | null
|
|
10
|
+
/** Which output the last attempt wrote to — `'local'` dir or `'s3'` bucket; null when no row yet. */
|
|
11
|
+
target?: 'local' | 's3' | null
|
|
12
|
+
/** Whether the runtime publisher actually produces files in THIS environment (prod + `output.auto`).
|
|
13
|
+
* `false` in dev / with static output off → a page can never turn "Live" here, so don't poll for it. */
|
|
14
|
+
generates?: boolean
|
|
15
|
+
/** The configured output destination (where the NEXT publish would go), independent of any row. */
|
|
16
|
+
driver?: 'local' | 's3'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const EMPTY: PublishStatusData = { route: null, status: null }
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Fetch (and refetch) the LIVE / generated publish state of the record the editor is on, for the right
|
|
23
|
+
* dot of the editor Ampel. Explicit `refresh()` (no auto-fetch) so it is testable in isolation and the
|
|
24
|
+
* caller controls timing: the editor refreshes on mount and again after each save (a save may (re)publish
|
|
25
|
+
* the page). Skips the request entirely when disabled (non-pageLike collection) or the record is unsaved
|
|
26
|
+
* (`id === 'new'` has no server row, hence no route yet). A failed fetch degrades to an empty status
|
|
27
|
+
* rather than surfacing an error — the ampel is a non-critical signal.
|
|
28
|
+
*/
|
|
29
|
+
export function usePublishStatus(args: {
|
|
30
|
+
collection: MaybeRefOrGetter<string>
|
|
31
|
+
id: MaybeRefOrGetter<string | number>
|
|
32
|
+
locale?: MaybeRefOrGetter<string | undefined>
|
|
33
|
+
enabled?: MaybeRefOrGetter<boolean>
|
|
34
|
+
}) {
|
|
35
|
+
const data = ref<PublishStatusData>({ ...EMPTY })
|
|
36
|
+
// Bumped by every `refresh()` / `refreshUntilSettled()` start; an in-flight poll compares the token it
|
|
37
|
+
// captured and bails the moment a newer call supersedes it (navigation, another save), so stale loops
|
|
38
|
+
// can't keep writing to `data`.
|
|
39
|
+
let pollToken = 0
|
|
40
|
+
|
|
41
|
+
async function fetchOnce(): Promise<void> {
|
|
42
|
+
// Guard every write by the token captured at call time: if a newer refresh/poll supersedes this one
|
|
43
|
+
// while its request is in flight, its (possibly stale) response must NOT overwrite the fresher data —
|
|
44
|
+
// two overlapping fetches (a rapid re-save superseding a poll) can otherwise resolve out of order.
|
|
45
|
+
const token = pollToken
|
|
46
|
+
const enabled = args.enabled === undefined ? true : toValue(args.enabled)
|
|
47
|
+
const id = toValue(args.id)
|
|
48
|
+
if (!enabled || id === 'new' || id == null) {
|
|
49
|
+
if (token === pollToken) data.value = { ...EMPTY }
|
|
50
|
+
return
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
const res = await $fetch<PublishStatusData>('/api/publish-status', {
|
|
54
|
+
query: { collection: toValue(args.collection), id, locale: toValue(args.locale) },
|
|
55
|
+
})
|
|
56
|
+
if (token === pollToken) data.value = res
|
|
57
|
+
} catch {
|
|
58
|
+
if (token === pollToken) data.value = { ...EMPTY }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One-shot fetch. Also cancels any running poll (this is the fresh source of truth). */
|
|
63
|
+
async function refresh(): Promise<void> {
|
|
64
|
+
pollToken++
|
|
65
|
+
await fetchOnce()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Poll the live status until it SETTLES, for the right lamp after a save: in prod a save enqueues an
|
|
70
|
+
* async (debounced) republish that finishes a moment later, so a single refresh would only ever catch the
|
|
71
|
+
* pre-republish row. Stops when the record has no route, the environment doesn't generate (`generates ===
|
|
72
|
+
* false` — dev / static output off: nothing will ever be produced, so polling is pointless), or a terminal
|
|
73
|
+
* outcome (`success`/`error`) from the NEW republish lands.
|
|
74
|
+
*
|
|
75
|
+
* `since` is the row's `updatedAt` BEFORE this save. `publish_status` is a latest-state upsert that is NOT
|
|
76
|
+
* cleared first, so a re-saved page keeps its prior success/error row until the fresh render rewrites it —
|
|
77
|
+
* without this baseline the very first poll would read that stale row and settle instantly (e.g. staying
|
|
78
|
+
* green "Live" for a re-save that actually failed to republish). So a terminal outcome only counts as
|
|
79
|
+
* settled once its `updatedAt` differs from `since`. Bounded by `attempts`; superseded-safe via `pollToken`.
|
|
80
|
+
*/
|
|
81
|
+
async function refreshUntilSettled(opts?: { attempts?: number; delayMs?: number; since?: string | null }): Promise<void> {
|
|
82
|
+
const token = ++pollToken
|
|
83
|
+
const attempts = opts?.attempts ?? 12
|
|
84
|
+
const delayMs = opts?.delayMs ?? 2000
|
|
85
|
+
const since = opts?.since ?? null
|
|
86
|
+
for (let i = 0; i < attempts; i++) {
|
|
87
|
+
if (token !== pollToken) return
|
|
88
|
+
await fetchOnce()
|
|
89
|
+
if (token !== pollToken) return
|
|
90
|
+
const d = data.value
|
|
91
|
+
const settled = (d.status === 'success' || d.status === 'error') && (d.updatedAt ?? null) !== since
|
|
92
|
+
if (!d.route || d.generates === false || settled) return
|
|
93
|
+
if (i < attempts - 1) await new Promise((resolve) => setTimeout(resolve, delayMs))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { data, refresh, refreshUntilSettled }
|
|
98
|
+
}
|