@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,199 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The editor "Ampel": TWO lamps side by side in the action bar, each a dot + ONE word + a details tooltip.
|
|
3
|
+
// LEFT — the SAVE / draft lifecycle of THIS record (its local vs. stored state):
|
|
4
|
+
// amber — work in flight: saving, or unsaved local changes not yet in the DB
|
|
5
|
+
// blue — saved but a Draft, so it is intentionally NOT on the live/static site
|
|
6
|
+
// green — saved and (for status collections) published, or just saved (no status field)
|
|
7
|
+
// RIGHT — the GENERATED state of this record's static page (pageLike collections only), read from
|
|
8
|
+
// `publish_status` via `/api/publish-status`:
|
|
9
|
+
// green — the route's last publish succeeded (the file is live; the tooltip names local vs S3)
|
|
10
|
+
// amber — published but no success row yet — a republish is in flight ("Generating…")
|
|
11
|
+
// neutral — this environment never produces the file (dev / static output off) → "Not built"
|
|
12
|
+
// red — the last publish for the route errored (render / write / S3) — details in the tooltip
|
|
13
|
+
// blue — a Draft: intentionally not generated, so there is no live file
|
|
14
|
+
import type { PublishStatusData } from '../composables/usePublishStatus'
|
|
15
|
+
|
|
16
|
+
const props = defineProps<{
|
|
17
|
+
dirty: boolean
|
|
18
|
+
saving?: boolean
|
|
19
|
+
hasStatus?: boolean
|
|
20
|
+
/** The LIVE (possibly unsaved) form status — drives the left lamp's word. */
|
|
21
|
+
status?: string
|
|
22
|
+
/** The last SAVED publish status — drives the RIGHT lamp's draft/published decision, so an unsaved
|
|
23
|
+
* dropdown switch never flips the generated-state indicator. Falls back to `status` when absent. */
|
|
24
|
+
savedStatus?: string
|
|
25
|
+
/** Only pageLike collections produce a static page → only they get the right (live) lamp. */
|
|
26
|
+
pageLike?: boolean
|
|
27
|
+
/** The record's last publish outcome (from `/api/publish-status`); null/absent while unknown. */
|
|
28
|
+
live?: PublishStatusData | null
|
|
29
|
+
}>()
|
|
30
|
+
const { t, lang } = useT()
|
|
31
|
+
|
|
32
|
+
// LEFT lamp — save + publish state. `word` is the single label shown; `detail` is the tooltip sentence.
|
|
33
|
+
const save = computed<{ tone: 'amber' | 'green' | 'blue'; word: string; detail: string }>(() => {
|
|
34
|
+
if (props.saving) return { tone: 'amber', word: t('editorStatus.word.saving'), detail: t('editorStatus.detail.saving') }
|
|
35
|
+
if (props.dirty) return { tone: 'amber', word: t('editorStatus.word.unsaved'), detail: t('editorStatus.detail.unsaved') }
|
|
36
|
+
if (props.hasStatus && props.status === 'draft') return { tone: 'blue', word: t('editorStatus.word.draft'), detail: t('editorStatus.detail.draft') }
|
|
37
|
+
if (props.hasStatus) return { tone: 'green', word: t('editorStatus.word.published'), detail: t('editorStatus.detail.published') }
|
|
38
|
+
return { tone: 'green', word: t('editorStatus.word.saved'), detail: t('editorStatus.detail.saved') }
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const updatedAtLabel = computed(() => {
|
|
42
|
+
const at = props.live?.updatedAt
|
|
43
|
+
if (!at) return ''
|
|
44
|
+
const d = new Date(at)
|
|
45
|
+
if (Number.isNaN(d.getTime())) return ''
|
|
46
|
+
return t('editorStatus.live.updatedAt', { time: d.toLocaleString(lang.value) })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
// RIGHT lamp — the live / generated state of this record's static page. Only a SAVED pageLike record with a
|
|
50
|
+
// resolved route has one (a non-pageLike collection produces no static page; an unsaved `new` record leaves
|
|
51
|
+
// `live.route` null → no right lamp until the record actually has a page).
|
|
52
|
+
const live = computed<{ tone: 'green' | 'red' | 'amber' | 'blue' | 'neutral'; word: string; detail: string; when?: string; error?: string } | null>(() => {
|
|
53
|
+
if (props.pageLike !== true || !props.live?.route) return null
|
|
54
|
+
// Key the draft/published decision off the SAVED status, not the live form value: an unsaved dropdown
|
|
55
|
+
// switch must not move the generated-state indicator. Switching a live page → Draft keeps it "Live" (the
|
|
56
|
+
// file stays on the server until the save prunes it); switching a saved Draft → Published keeps it "Not
|
|
57
|
+
// live" (nothing is generated until the publish is actually saved). A DISTINCT word from the left lamp's
|
|
58
|
+
// "Draft" so the two lamps read as two signals (save-state vs generated-state), not a repeat.
|
|
59
|
+
const saved = props.savedStatus ?? props.status
|
|
60
|
+
if (props.hasStatus && saved === 'draft')
|
|
61
|
+
return { tone: 'blue', word: t('editorStatus.word.notLive'), detail: t('editorStatus.live.draft') }
|
|
62
|
+
const s = props.live?.status ?? null
|
|
63
|
+
if (s === 'error')
|
|
64
|
+
return { tone: 'red', word: t('editorStatus.word.error'), detail: t('editorStatus.live.error'), when: updatedAtLabel.value, error: props.live?.error ?? '' }
|
|
65
|
+
if (s === 'success') {
|
|
66
|
+
// Where the file actually landed — surface local vs S3 in the tooltip ("stored on S3").
|
|
67
|
+
const onS3 = (props.live?.target ?? props.live?.driver) === 's3'
|
|
68
|
+
return { tone: 'green', word: t('editorStatus.word.live'), detail: t(onS3 ? 'editorStatus.live.liveS3' : 'editorStatus.live.liveLocal'), when: updatedAtLabel.value }
|
|
69
|
+
}
|
|
70
|
+
// No success row yet. Only an explicit generates:false means "this environment produces nothing" (dev /
|
|
71
|
+
// static output off) → a calm neutral "Not built". Unknown (undefined) → assume a publish is in flight.
|
|
72
|
+
if (props.live?.generates === false)
|
|
73
|
+
return { tone: 'neutral', word: t('editorStatus.word.notBuilt'), detail: t('editorStatus.live.notBuilt') }
|
|
74
|
+
return { tone: 'amber', word: t('editorStatus.word.generating'), detail: t('editorStatus.live.pending') }
|
|
75
|
+
})
|
|
76
|
+
</script>
|
|
77
|
+
|
|
78
|
+
<template>
|
|
79
|
+
<span class="editor-status-group">
|
|
80
|
+
<!-- Polite live region: announces save-state transitions (Saved → Unsaved → Saving…) to screen readers.
|
|
81
|
+
Visually hidden; not a tooltip. -->
|
|
82
|
+
<span class="editor-status-sr" role="status" aria-live="polite">{{ save.detail }}</span>
|
|
83
|
+
|
|
84
|
+
<UiTooltip side="bottom">
|
|
85
|
+
<!-- A focusable status indicator, NOT a <button>: it has no action, and a button whose accessible
|
|
86
|
+
name contains "Saved"/"Unsaved" would collide with `getByRole('button', {name:'Save'})`. aria-label
|
|
87
|
+
leads with the visible word so it stays part of the accessible name, then adds the detail for AT. -->
|
|
88
|
+
<span class="editor-status" tabindex="0" :data-tone="save.tone" :aria-label="`${save.word} — ${save.detail}`">
|
|
89
|
+
<span class="editor-status__dot" aria-hidden="true" />
|
|
90
|
+
<span class="editor-status__word">{{ save.word }}</span>
|
|
91
|
+
</span>
|
|
92
|
+
<template #content>
|
|
93
|
+
<span class="editor-status-tip">
|
|
94
|
+
<strong class="editor-status-tip__detail">{{ save.detail }}</strong>
|
|
95
|
+
</span>
|
|
96
|
+
</template>
|
|
97
|
+
</UiTooltip>
|
|
98
|
+
|
|
99
|
+
<UiTooltip v-if="live" side="bottom">
|
|
100
|
+
<span class="editor-status-live" tabindex="0" :data-tone="live.tone" :aria-label="`${live.word} — ${live.detail}`">
|
|
101
|
+
<span class="editor-status-live__dot" aria-hidden="true" />
|
|
102
|
+
<span class="editor-status-live__word">{{ live.word }}</span>
|
|
103
|
+
</span>
|
|
104
|
+
<template #content>
|
|
105
|
+
<span class="editor-status-tip">
|
|
106
|
+
<strong class="editor-status-tip__detail">{{ live.detail }}</strong>
|
|
107
|
+
<span v-if="live.when" class="editor-status-tip__when">{{ live.when }}</span>
|
|
108
|
+
<span v-if="live.tone === 'red' && live.error" class="editor-status-tip__err">{{ live.error }}</span>
|
|
109
|
+
</span>
|
|
110
|
+
</template>
|
|
111
|
+
</UiTooltip>
|
|
112
|
+
</span>
|
|
113
|
+
</template>
|
|
114
|
+
|
|
115
|
+
<style scoped lang="scss">
|
|
116
|
+
.editor-status-group {
|
|
117
|
+
display: inline-flex;
|
|
118
|
+
align-items: center;
|
|
119
|
+
gap: var(--space-3);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Visually-hidden polite status region (mirrors the ui sr-only mixin; admin can't @use a ui-layer scss).
|
|
123
|
+
.editor-status-sr {
|
|
124
|
+
position: absolute;
|
|
125
|
+
width: 1px;
|
|
126
|
+
height: 1px;
|
|
127
|
+
padding: 0;
|
|
128
|
+
margin: -1px;
|
|
129
|
+
overflow: hidden;
|
|
130
|
+
clip: rect(0, 0, 0, 0);
|
|
131
|
+
white-space: nowrap;
|
|
132
|
+
border: 0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Both lamps are focusable status indicators (tabindex spans, not buttons) so the tooltip opens on hover
|
|
136
|
+
// AND keyboard focus, without adding a "Save"-named button that would clash with Save-button selectors.
|
|
137
|
+
.editor-status,
|
|
138
|
+
.editor-status-live {
|
|
139
|
+
display: inline-flex;
|
|
140
|
+
align-items: center;
|
|
141
|
+
gap: var(--space-2);
|
|
142
|
+
cursor: help;
|
|
143
|
+
|
|
144
|
+
&__dot {
|
|
145
|
+
width: 0.6rem;
|
|
146
|
+
height: 0.6rem;
|
|
147
|
+
border-radius: var(--radius-full);
|
|
148
|
+
background: var(--_tone, var(--color-text-subtle));
|
|
149
|
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--_tone, transparent) 22%, transparent);
|
|
150
|
+
flex: none;
|
|
151
|
+
}
|
|
152
|
+
&__word {
|
|
153
|
+
font-size: var(--text-sm);
|
|
154
|
+
color: var(--color-text-muted);
|
|
155
|
+
white-space: nowrap;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
&[data-tone='amber'] {
|
|
159
|
+
--_tone: var(--color-warning);
|
|
160
|
+
}
|
|
161
|
+
&[data-tone='green'] {
|
|
162
|
+
--_tone: var(--color-success);
|
|
163
|
+
}
|
|
164
|
+
&[data-tone='blue'] {
|
|
165
|
+
--_tone: var(--color-primary);
|
|
166
|
+
}
|
|
167
|
+
&[data-tone='red'] {
|
|
168
|
+
--_tone: var(--color-danger);
|
|
169
|
+
}
|
|
170
|
+
&[data-tone='neutral'] {
|
|
171
|
+
--_tone: var(--color-text-subtle);
|
|
172
|
+
}
|
|
173
|
+
&:focus-visible {
|
|
174
|
+
outline: 2px solid var(--color-primary);
|
|
175
|
+
outline-offset: 2px;
|
|
176
|
+
border-radius: var(--radius-sm);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
</style>
|
|
180
|
+
|
|
181
|
+
<!-- Not scoped: the tooltip content is teleported to <body> by Reka, where a scoped data-v selector no
|
|
182
|
+
longer matches (same reason UiTooltip itself keeps global styles). -->
|
|
183
|
+
<style lang="scss">
|
|
184
|
+
.editor-status-tip {
|
|
185
|
+
display: flex;
|
|
186
|
+
flex-direction: column;
|
|
187
|
+
gap: var(--space-1);
|
|
188
|
+
|
|
189
|
+
&__when {
|
|
190
|
+
color: var(--color-text-muted);
|
|
191
|
+
font-size: var(--text-xs);
|
|
192
|
+
}
|
|
193
|
+
&__err {
|
|
194
|
+
color: var(--color-danger);
|
|
195
|
+
font-size: var(--text-xs);
|
|
196
|
+
word-break: break-word;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
</style>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Fallback body when a collection's `editor` type has no registered body (an extension misconfig or a
|
|
3
|
+
// missing plugin). Fails visibly instead of rendering blank.
|
|
4
|
+
defineProps<{ editor: string }>()
|
|
5
|
+
const { t } = useT()
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<div class="editor-unsupported">
|
|
10
|
+
<p class="editor-unsupported__note">{{ t('editor.unsupported', { type: editor }) }}</p>
|
|
11
|
+
</div>
|
|
12
|
+
</template>
|
|
13
|
+
|
|
14
|
+
<style lang="scss">
|
|
15
|
+
.editor-unsupported {
|
|
16
|
+
flex: 1 1 auto;
|
|
17
|
+
min-height: 0;
|
|
18
|
+
|
|
19
|
+
&__note {
|
|
20
|
+
font-size: var(--text-sm);
|
|
21
|
+
color: var(--color-text-muted);
|
|
22
|
+
padding: var(--space-3) var(--space-4);
|
|
23
|
+
border: 1px solid var(--color-border);
|
|
24
|
+
border-radius: var(--radius-md);
|
|
25
|
+
background: var(--color-surface-2);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
</style>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The schema-driven default editor body: a plain field list. All state comes from the editor form
|
|
3
|
+
// context provided by CollectionEditor; this body only lays it out.
|
|
4
|
+
const ctx = useEditorFormContext()
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<template>
|
|
8
|
+
<div class="editor__flat">
|
|
9
|
+
<PageFieldsPane v-bind="ctx.pageFieldsBindings.value" v-on="ctx.pageFieldsHandlers" />
|
|
10
|
+
</div>
|
|
11
|
+
</template>
|
|
12
|
+
|
|
13
|
+
<style lang="scss">
|
|
14
|
+
// Flat-collection field list: its own scroll region, so the shell's actions row stays pinned below it.
|
|
15
|
+
.editor__flat {
|
|
16
|
+
flex: 1 1 auto;
|
|
17
|
+
min-height: 0;
|
|
18
|
+
overflow-y: auto;
|
|
19
|
+
display: flex;
|
|
20
|
+
flex-direction: column;
|
|
21
|
+
gap: var(--space-4);
|
|
22
|
+
}
|
|
23
|
+
</style>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Pruvious-style content-locale switcher for the record editor. Shows every configured website locale:
|
|
3
|
+
// the active one as a label, an existing sibling as a pen (edit) + copy control, a missing one as a
|
|
4
|
+
// "+" (create) link. Content locales only — not the admin-UI language.
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
collection: string
|
|
7
|
+
id: string
|
|
8
|
+
mode: 'single' | 'multi'
|
|
9
|
+
current: string
|
|
10
|
+
translations: Record<string, number | null>
|
|
11
|
+
group?: string
|
|
12
|
+
}>()
|
|
13
|
+
const emit = defineEmits<{ copyFrom: [locale: string] }>()
|
|
14
|
+
|
|
15
|
+
const { locales } = useContentLocales()
|
|
16
|
+
|
|
17
|
+
/** Existing sibling row id for a locale (multi only), or null when there is no translation yet. */
|
|
18
|
+
function existingId(loc: string): number | null {
|
|
19
|
+
const v = props.translations[loc]
|
|
20
|
+
return typeof v === 'number' ? v : null
|
|
21
|
+
}
|
|
22
|
+
function createLink(loc: string): string {
|
|
23
|
+
const q = new URLSearchParams({ locale: loc })
|
|
24
|
+
if (props.group) q.set('group', props.group)
|
|
25
|
+
return `/admin/${props.collection}/new?${q.toString()}`
|
|
26
|
+
}
|
|
27
|
+
const up = (s: string) => s.toUpperCase()
|
|
28
|
+
const { t } = useT()
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<template>
|
|
32
|
+
<div class="locale-bar" role="group" :aria-label="t('localeBar.groupLabel')">
|
|
33
|
+
<template v-for="loc in locales" :key="loc">
|
|
34
|
+
<span v-if="loc === current" class="locale-bar__item locale-bar__item--active" aria-current="true">{{ up(loc) }}</span>
|
|
35
|
+
|
|
36
|
+
<NuxtLink
|
|
37
|
+
v-else-if="mode === 'single'"
|
|
38
|
+
class="locale-bar__item locale-bar__btn"
|
|
39
|
+
:to="`/admin/${collection}?locale=${loc}`"
|
|
40
|
+
:aria-label="t('localeBar.editLocale', { loc: up(loc) })"
|
|
41
|
+
>{{ up(loc) }}<UiIcon name="pencil" :size="14" /></NuxtLink>
|
|
42
|
+
|
|
43
|
+
<span v-else-if="existingId(loc) !== null" class="locale-bar__item">
|
|
44
|
+
<NuxtLink class="locale-bar__btn" :to="`/admin/${collection}/${existingId(loc)}?locale=${loc}`" :aria-label="t('localeBar.editLocale', { loc: up(loc) })">{{ up(loc) }}<UiIcon name="pencil" :size="14" /></NuxtLink>
|
|
45
|
+
<button type="button" class="locale-bar__btn locale-bar__btn--copy" :aria-label="t('localeBar.copyInto', { loc: up(loc), current: up(current) })" @click="emit('copyFrom', loc)"><UiIcon name="copy" :size="14" /></button>
|
|
46
|
+
</span>
|
|
47
|
+
|
|
48
|
+
<NuxtLink
|
|
49
|
+
v-else
|
|
50
|
+
class="locale-bar__item locale-bar__btn locale-bar__btn--add"
|
|
51
|
+
:to="createLink(loc)"
|
|
52
|
+
:aria-label="t('localeBar.createTranslation', { loc: up(loc) })"
|
|
53
|
+
>{{ up(loc) }}<UiIcon name="plus" :size="14" /></NuxtLink>
|
|
54
|
+
</template>
|
|
55
|
+
</div>
|
|
56
|
+
</template>
|
|
57
|
+
|
|
58
|
+
<style lang="scss">
|
|
59
|
+
.locale-bar {
|
|
60
|
+
display: flex;
|
|
61
|
+
align-items: center;
|
|
62
|
+
gap: var(--space-2);
|
|
63
|
+
|
|
64
|
+
&__item {
|
|
65
|
+
display: inline-flex;
|
|
66
|
+
align-items: center;
|
|
67
|
+
gap: var(--space-1);
|
|
68
|
+
font-size: var(--text-sm);
|
|
69
|
+
font-weight: var(--weight-medium);
|
|
70
|
+
|
|
71
|
+
&--active {
|
|
72
|
+
padding: var(--space-1) var(--space-2);
|
|
73
|
+
border-radius: var(--radius-sm);
|
|
74
|
+
background: var(--color-surface);
|
|
75
|
+
border: 1px solid var(--color-border);
|
|
76
|
+
color: var(--color-text);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
&__btn {
|
|
80
|
+
display: inline-flex;
|
|
81
|
+
align-items: center;
|
|
82
|
+
gap: var(--space-1);
|
|
83
|
+
padding: var(--space-1) var(--space-2);
|
|
84
|
+
border: 1px solid var(--color-border);
|
|
85
|
+
border-radius: var(--radius-sm);
|
|
86
|
+
background: transparent;
|
|
87
|
+
color: var(--color-text-muted);
|
|
88
|
+
text-decoration: none;
|
|
89
|
+
cursor: pointer;
|
|
90
|
+
|
|
91
|
+
&:hover {
|
|
92
|
+
border-color: var(--color-text-muted);
|
|
93
|
+
color: var(--color-text);
|
|
94
|
+
}
|
|
95
|
+
&--add {
|
|
96
|
+
border-style: solid;
|
|
97
|
+
border-color: var(--color-border);
|
|
98
|
+
color: var(--color-text-muted);
|
|
99
|
+
}
|
|
100
|
+
&--copy {
|
|
101
|
+
padding: var(--space-1);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
</style>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { FieldDef } from '../../../core/server/utils/defineCollection'
|
|
3
|
+
import type { LayoutNode } from '../../../core/server/utils/field-layout'
|
|
4
|
+
import type { SeoMeta } from '../../../core/server/utils/seo'
|
|
5
|
+
|
|
6
|
+
// The collection (page) field list — delegates to the shared <FieldLayout> renderer (rows / groups /
|
|
7
|
+
// widths, or one field per row when the collection declares no layout), shared by the flat-collection
|
|
8
|
+
// form and the 3-pane editor's root-selected pane so the wiring lives in exactly one place. pageLike
|
|
9
|
+
// collections also get their routable `path` (slug); seo-enabled ones get the SEO section — both as
|
|
10
|
+
// page-level system fields bracketing the collection fields.
|
|
11
|
+
const props = defineProps<{
|
|
12
|
+
fields: Record<string, FieldDef>
|
|
13
|
+
/** Normalized admin editor layout (from the serialized collection). Absent → one field per row. */
|
|
14
|
+
fieldLayout?: LayoutNode[]
|
|
15
|
+
values: Record<string, unknown>
|
|
16
|
+
errors: Record<string, string>
|
|
17
|
+
/** Root field keys holding a stale reference — shown as a non-blocking warning note. */
|
|
18
|
+
deadFields?: Set<string>
|
|
19
|
+
locale: string
|
|
20
|
+
pageLike?: boolean
|
|
21
|
+
seo?: boolean
|
|
22
|
+
status?: boolean
|
|
23
|
+
disabled?: boolean
|
|
24
|
+
}>()
|
|
25
|
+
const emit = defineEmits<{ update: [name: string, value: unknown] }>()
|
|
26
|
+
const { t } = useT()
|
|
27
|
+
|
|
28
|
+
// Live preview of the slug the server will auto-generate from the title while the field is left blank
|
|
29
|
+
// (the server slugifies the title on save). Falls back to '/' when there is no title yet.
|
|
30
|
+
const slugPlaceholder = computed(() => {
|
|
31
|
+
const s = slugify(recordTitle(props.fields, props.values))
|
|
32
|
+
return s ? `/${s}` : '/'
|
|
33
|
+
})
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<!-- Publish gate (the `status` system column). The primary page-level control, so it leads the pane:
|
|
38
|
+
Draft keeps the page out of the generated site; Published renders + auto-publishes it. -->
|
|
39
|
+
<UiField
|
|
40
|
+
v-if="status"
|
|
41
|
+
class="page-settings__status"
|
|
42
|
+
:label="t('pageSettings.statusLabel')"
|
|
43
|
+
:hint="t('pageSettings.statusHint')"
|
|
44
|
+
>
|
|
45
|
+
<template #default="f">
|
|
46
|
+
<UiSelect
|
|
47
|
+
:model-value="(values.status as string) ?? 'draft'"
|
|
48
|
+
:options="[
|
|
49
|
+
{ label: t('pageSettings.statusDraft'), value: 'draft' },
|
|
50
|
+
{ label: t('pageSettings.statusPublished'), value: 'published' },
|
|
51
|
+
]"
|
|
52
|
+
:disabled="disabled"
|
|
53
|
+
v-bind="f"
|
|
54
|
+
@update:model-value="(v) => emit('update', 'status', v)"
|
|
55
|
+
/>
|
|
56
|
+
</template>
|
|
57
|
+
</UiField>
|
|
58
|
+
|
|
59
|
+
<!-- The collection (page) fields, laid out by the shared renderer (rows / groups / widths, or one field
|
|
60
|
+
per row when the collection declares no `fieldLayout`). The condition gate + dead-ref note live inside. -->
|
|
61
|
+
<FieldLayout
|
|
62
|
+
:layout="fieldLayout"
|
|
63
|
+
:fields="fields"
|
|
64
|
+
:values="values"
|
|
65
|
+
:errors="errors"
|
|
66
|
+
:dead-fields="deadFields"
|
|
67
|
+
:locale="locale"
|
|
68
|
+
:disabled="disabled"
|
|
69
|
+
@update="(name, value) => emit('update', name, value)"
|
|
70
|
+
/>
|
|
71
|
+
|
|
72
|
+
<!-- Page slug (the routable path). A system column on pageLike collections, edited like a field. -->
|
|
73
|
+
<UiField
|
|
74
|
+
v-if="pageLike"
|
|
75
|
+
class="page-settings__slug"
|
|
76
|
+
:label="t('pageSettings.slugLabel')"
|
|
77
|
+
:hint="t('pageSettings.slugHint')"
|
|
78
|
+
:error="errors.path || null"
|
|
79
|
+
>
|
|
80
|
+
<template #default="f">
|
|
81
|
+
<UiTextInput
|
|
82
|
+
:model-value="(values.path as string) ?? ''"
|
|
83
|
+
:disabled="disabled"
|
|
84
|
+
:placeholder="slugPlaceholder"
|
|
85
|
+
v-bind="f"
|
|
86
|
+
@update:model-value="(v) => emit('update', 'path', v)"
|
|
87
|
+
/>
|
|
88
|
+
</template>
|
|
89
|
+
</UiField>
|
|
90
|
+
|
|
91
|
+
<!-- Page SEO (meta title/description/noindex + Google preview). The `seo` JSON system column. -->
|
|
92
|
+
<SeoFields
|
|
93
|
+
v-if="seo"
|
|
94
|
+
:value="(values.seo as SeoMeta) ?? {}"
|
|
95
|
+
:page-title="(values.title as string) ?? ''"
|
|
96
|
+
:path="(values.path as string) ?? ''"
|
|
97
|
+
:locale="locale"
|
|
98
|
+
:disabled="disabled"
|
|
99
|
+
@update="(v) => emit('update', 'seo', v)"
|
|
100
|
+
/>
|
|
101
|
+
</template>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { PageFieldsBindings } from '../utils/editor-form-context'
|
|
3
|
+
|
|
4
|
+
// The page-fields fragment shared by both editor bodies: an optional content-locale switcher
|
|
5
|
+
// (labelled like a page field) sitting above the collection (page) field list. Centralises the
|
|
6
|
+
// LocaleBar + PageFields wiring so the flat form and the 3-pane editor's root pane stay in sync.
|
|
7
|
+
// Props are the shared `PageFieldsBindings` type — the same shape the shell pre-builds and provides.
|
|
8
|
+
defineProps<PageFieldsBindings>()
|
|
9
|
+
const emit = defineEmits<{ copyFrom: [locale: string]; update: [name: string, value: unknown] }>()
|
|
10
|
+
const { t } = useT()
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<div v-if="translatable" class="ui-field">
|
|
15
|
+
<span class="ui-field__label">{{ t('localeBar.fieldLabel') }}</span>
|
|
16
|
+
<LocaleBar
|
|
17
|
+
:collection="collection"
|
|
18
|
+
:id="id"
|
|
19
|
+
:mode="mode"
|
|
20
|
+
:current="locale"
|
|
21
|
+
:translations="translations"
|
|
22
|
+
:group="group"
|
|
23
|
+
@copy-from="(loc) => emit('copyFrom', loc)"
|
|
24
|
+
/>
|
|
25
|
+
</div>
|
|
26
|
+
<PageFields
|
|
27
|
+
:fields="fields"
|
|
28
|
+
:field-layout="fieldLayout"
|
|
29
|
+
:values="values"
|
|
30
|
+
:errors="errors"
|
|
31
|
+
:dead-fields="deadFields"
|
|
32
|
+
:locale="locale"
|
|
33
|
+
:page-like="pageLike"
|
|
34
|
+
:seo="seo"
|
|
35
|
+
:status="status"
|
|
36
|
+
:disabled="disabled"
|
|
37
|
+
@update="(name, value) => emit('update', name, value)"
|
|
38
|
+
/>
|
|
39
|
+
</template>
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue'
|
|
3
|
+
import type { SeoMeta } from '../../../core/server/utils/seo'
|
|
4
|
+
import type { FieldDef } from '../../../core/server/utils/defineCollection'
|
|
5
|
+
import { localePath } from '../../../core/app/utils/locale-path'
|
|
6
|
+
|
|
7
|
+
// The page-level SEO section: edit the meta title/description/noindex (the `seo` system column) with a
|
|
8
|
+
// live Google-result preview. Emits the whole merged SeoMeta so the parent routes it through setField.
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
value: SeoMeta
|
|
11
|
+
pageTitle?: string
|
|
12
|
+
path?: string
|
|
13
|
+
locale: string
|
|
14
|
+
disabled?: boolean
|
|
15
|
+
}>()
|
|
16
|
+
const emit = defineEmits<{ update: [seo: SeoMeta] }>()
|
|
17
|
+
const { t } = useT()
|
|
18
|
+
const { primary, prefixPrimary } = useContentLocales()
|
|
19
|
+
|
|
20
|
+
// Soft guidance limits (Google truncates ~ these lengths).
|
|
21
|
+
const TITLE_MAX = 60
|
|
22
|
+
const DESC_MAX = 160
|
|
23
|
+
|
|
24
|
+
function patch(next: Partial<SeoMeta>) {
|
|
25
|
+
emit('update', { ...props.value, ...next })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const previewTitle = computed(() => props.value.title?.trim() || props.pageTitle?.trim() || t('seo.untitled'))
|
|
29
|
+
const previewDesc = computed(() => props.value.description?.trim() || t('seo.noDescription'))
|
|
30
|
+
|
|
31
|
+
const host = computed(() => {
|
|
32
|
+
try { return useRequestURL().host } catch { return '' }
|
|
33
|
+
})
|
|
34
|
+
const previewUrl = computed(() => `${host.value}${localePath(props.path || '/', props.locale, primary, prefixPrimary)}`)
|
|
35
|
+
|
|
36
|
+
const titleLen = computed(() => (props.value.title ?? '').length)
|
|
37
|
+
const descLen = computed(() => (props.value.description ?? '').length)
|
|
38
|
+
|
|
39
|
+
// The social-share image (og:image) rides the standard media widget over a synthetic single-media field
|
|
40
|
+
// def — same picker/thumb/remove UX as any media field, stored as `seo.image` (a media id).
|
|
41
|
+
const socialImageField = { type: 'media', options: { accept: 'image' } } as FieldDef
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<template>
|
|
45
|
+
<div class="seo-fields">
|
|
46
|
+
<p class="seo-fields__section-label">{{ t('seo.sectionLabel') }}</p>
|
|
47
|
+
|
|
48
|
+
<!-- Google SERP-style preview. Decorative (it mirrors the inputs below) → hidden from SRs. -->
|
|
49
|
+
<div class="seo-preview" aria-hidden="true">
|
|
50
|
+
<div class="seo-preview__url">{{ previewUrl }}</div>
|
|
51
|
+
<div class="seo-preview__title">{{ previewTitle }}</div>
|
|
52
|
+
<div class="seo-preview__desc">{{ previewDesc }}</div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<UiField class="seo-fields__title" :label="t('seo.metaTitle')" :hint="`${titleLen}/${TITLE_MAX}`">
|
|
56
|
+
<template #default="f">
|
|
57
|
+
<UiTextInput
|
|
58
|
+
:model-value="value.title ?? ''"
|
|
59
|
+
:placeholder="pageTitle"
|
|
60
|
+
:disabled="disabled"
|
|
61
|
+
v-bind="f"
|
|
62
|
+
@update:model-value="(v) => patch({ title: v ?? '' })"
|
|
63
|
+
/>
|
|
64
|
+
</template>
|
|
65
|
+
</UiField>
|
|
66
|
+
|
|
67
|
+
<UiField class="seo-fields__desc" :label="t('seo.metaDescription')" :hint="`${descLen}/${DESC_MAX}`">
|
|
68
|
+
<template #default="f">
|
|
69
|
+
<UiTextarea
|
|
70
|
+
:model-value="value.description ?? ''"
|
|
71
|
+
:rows="3"
|
|
72
|
+
:disabled="disabled"
|
|
73
|
+
v-bind="f"
|
|
74
|
+
@update:model-value="(v) => patch({ description: v ?? '' })"
|
|
75
|
+
/>
|
|
76
|
+
</template>
|
|
77
|
+
</UiField>
|
|
78
|
+
|
|
79
|
+
<UiField class="seo-fields__image" :label="t('seo.socialImage')" :hint="t('seo.socialImageHint')">
|
|
80
|
+
<template #default="f">
|
|
81
|
+
<FieldMedia
|
|
82
|
+
:field="socialImageField"
|
|
83
|
+
name="seoImage"
|
|
84
|
+
:locale="locale"
|
|
85
|
+
:disabled="disabled"
|
|
86
|
+
v-bind="f"
|
|
87
|
+
:model-value="value.image ?? null"
|
|
88
|
+
@update:model-value="(v) => patch({ image: typeof v === 'number' ? v : null })"
|
|
89
|
+
/>
|
|
90
|
+
</template>
|
|
91
|
+
</UiField>
|
|
92
|
+
|
|
93
|
+
<label class="seo-fields__noindex">
|
|
94
|
+
<UiCheckbox
|
|
95
|
+
:model-value="!!value.noindex"
|
|
96
|
+
:disabled="disabled"
|
|
97
|
+
@update:model-value="(v) => patch({ noindex: v })"
|
|
98
|
+
/>
|
|
99
|
+
<span>{{ t('seo.noindexLabel') }}</span>
|
|
100
|
+
</label>
|
|
101
|
+
</div>
|
|
102
|
+
</template>
|
|
103
|
+
|
|
104
|
+
<style lang="scss">
|
|
105
|
+
.seo-fields {
|
|
106
|
+
display: flex;
|
|
107
|
+
flex-direction: column;
|
|
108
|
+
gap: var(--space-4);
|
|
109
|
+
|
|
110
|
+
&__section-label {
|
|
111
|
+
margin: 0;
|
|
112
|
+
font-size: var(--text-sm);
|
|
113
|
+
font-weight: var(--weight-medium);
|
|
114
|
+
color: var(--color-text-muted);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
&__noindex {
|
|
118
|
+
display: inline-flex;
|
|
119
|
+
align-items: center;
|
|
120
|
+
gap: var(--space-2);
|
|
121
|
+
font-size: var(--text-sm);
|
|
122
|
+
cursor: pointer;
|
|
123
|
+
|
|
124
|
+
input {
|
|
125
|
+
width: 1rem;
|
|
126
|
+
height: 1rem;
|
|
127
|
+
accent-color: var(--color-primary);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// A restrained take on a Google result card — enough to gauge truncation, not a pixel-perfect clone.
|
|
133
|
+
.seo-preview {
|
|
134
|
+
padding: var(--space-3);
|
|
135
|
+
border: 1px solid var(--color-border);
|
|
136
|
+
border-radius: var(--radius-md);
|
|
137
|
+
background: var(--color-surface);
|
|
138
|
+
|
|
139
|
+
&__url {
|
|
140
|
+
font-size: var(--text-xs, 0.75rem);
|
|
141
|
+
color: var(--color-text-muted);
|
|
142
|
+
white-space: nowrap;
|
|
143
|
+
overflow: hidden;
|
|
144
|
+
text-overflow: ellipsis;
|
|
145
|
+
}
|
|
146
|
+
&__title {
|
|
147
|
+
margin-top: var(--space-1);
|
|
148
|
+
font-size: var(--text-lg);
|
|
149
|
+
line-height: 1.3;
|
|
150
|
+
color: var(--color-link, #1a0dab);
|
|
151
|
+
overflow: hidden;
|
|
152
|
+
text-overflow: ellipsis;
|
|
153
|
+
display: -webkit-box;
|
|
154
|
+
-webkit-line-clamp: 1;
|
|
155
|
+
line-clamp: 1;
|
|
156
|
+
-webkit-box-orient: vertical;
|
|
157
|
+
}
|
|
158
|
+
&__desc {
|
|
159
|
+
margin-top: var(--space-1);
|
|
160
|
+
font-size: var(--text-sm);
|
|
161
|
+
color: var(--color-text);
|
|
162
|
+
overflow: hidden;
|
|
163
|
+
display: -webkit-box;
|
|
164
|
+
-webkit-line-clamp: 2;
|
|
165
|
+
line-clamp: 2;
|
|
166
|
+
-webkit-box-orient: vertical;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
</style>
|