@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,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collapsed/expanded state for the admin navigation rail.
|
|
3
|
+
* Backed by a cookie so the choice persists across reloads and stays SSR-safe
|
|
4
|
+
* (no hydration mismatch — server and client read the same value).
|
|
5
|
+
*/
|
|
6
|
+
export function useRailCollapsed() {
|
|
7
|
+
const collapsed = useCookie<boolean>('kestrel-rail-collapsed', { default: () => false })
|
|
8
|
+
|
|
9
|
+
function toggle() {
|
|
10
|
+
collapsed.value = !collapsed.value
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return { collapsed, toggle }
|
|
14
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type ThemeName = 'light' | 'dark'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The initial admin theme: an explicit stored choice wins; otherwise follow the OS
|
|
5
|
+
* `prefers-color-scheme`; default to light. Pure so it can be unit-tested without a DOM.
|
|
6
|
+
*/
|
|
7
|
+
export function resolveInitialTheme(opts: { stored?: string | null; prefersDark?: boolean }): ThemeName {
|
|
8
|
+
if (opts.stored === 'light' || opts.stored === 'dark') return opts.stored
|
|
9
|
+
return opts.prefersDark ? 'dark' : 'light'
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Admin colour theme (light/dark). The explicit choice is cookie-backed so it persists and is
|
|
14
|
+
* SSR-safe (server and client read the same value — no hydration mismatch). Until a choice is made
|
|
15
|
+
* the theme follows the OS preference, picked up client-side. The admin layout binds `theme` onto
|
|
16
|
+
* `<html data-theme>`; `_tokens.scss` keys the palette off that attribute.
|
|
17
|
+
*/
|
|
18
|
+
export function useTheme() {
|
|
19
|
+
const stored = useCookie<ThemeName | null>('kestrel-admin-theme', { default: () => null })
|
|
20
|
+
const prefersDark = ref(false)
|
|
21
|
+
let mq: MediaQueryList | null = null
|
|
22
|
+
const onChange = (e: MediaQueryListEvent) => { prefersDark.value = e.matches }
|
|
23
|
+
|
|
24
|
+
onMounted(() => {
|
|
25
|
+
mq = window.matchMedia('(prefers-color-scheme: dark)')
|
|
26
|
+
prefersDark.value = mq.matches
|
|
27
|
+
mq.addEventListener('change', onChange)
|
|
28
|
+
})
|
|
29
|
+
onUnmounted(() => mq?.removeEventListener('change', onChange))
|
|
30
|
+
|
|
31
|
+
const theme = computed<ThemeName>(() => resolveInitialTheme({ stored: stored.value, prefersDark: prefersDark.value }))
|
|
32
|
+
const setTheme = (t: ThemeName) => { stored.value = t }
|
|
33
|
+
const toggle = () => setTheme(theme.value === 'dark' ? 'light' : 'dark')
|
|
34
|
+
|
|
35
|
+
return { theme, toggle, setTheme }
|
|
36
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Confirm before navigating away from unsaved editor changes. Registers BOTH route guards on purpose:
|
|
5
|
+
* `onBeforeRouteLeave` fires when the matched route record is dropped (editor → list, rail nav), but
|
|
6
|
+
* Vue Router fires `onBeforeRouteUpdate` instead when a param/query-only change reuses the SAME record —
|
|
7
|
+
* which is exactly what the LocaleBar does (edit a sibling translation `/admin/{coll}/{id}`,
|
|
8
|
+
* create-and-link `/admin/{coll}/new?…`, switch a singleton's `?locale`). Without the update guard those
|
|
9
|
+
* same-record transitions silently discard in-progress edits (the `key: route.fullPath` remount only
|
|
10
|
+
* happens AFTER the router has already committed the navigation, so it can't veto it).
|
|
11
|
+
*
|
|
12
|
+
* `isDirty` / `skip` / `message` are read lazily on each navigation so the guard always sees current
|
|
13
|
+
* state. Returning `false` from a guard cancels the navigation; `skip()` lets a post-save / delete
|
|
14
|
+
* redirect pass straight through.
|
|
15
|
+
*/
|
|
16
|
+
export function useUnsavedGuard(isDirty: () => boolean, message: () => string, skip: () => boolean = () => false): void {
|
|
17
|
+
const guard = () => {
|
|
18
|
+
if (!skip() && isDirty() && !confirm(message())) return false
|
|
19
|
+
}
|
|
20
|
+
onBeforeRouteLeave(guard)
|
|
21
|
+
onBeforeRouteUpdate(guard)
|
|
22
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The admin design system (tokens / reset / base) loads HERE — with the admin-layout chunk — rather than
|
|
3
|
+
// app-globally, so it stays scoped to /admin and never leaks Kestrel's reset onto a consumer's public site
|
|
4
|
+
// (which ships its own CSS, e.g. Bootstrap). Every admin route uses this layout, so coverage is complete.
|
|
5
|
+
import '../../../ui/app/assets/scss/main.scss'
|
|
6
|
+
|
|
7
|
+
const { authenticated } = useAuth()
|
|
8
|
+
const { collapsed, toggle: toggleRail } = useRailCollapsed()
|
|
9
|
+
const { theme, toggle: toggleTheme } = useTheme()
|
|
10
|
+
const { t } = useT()
|
|
11
|
+
|
|
12
|
+
// Bind the resolved theme onto <html> so the admin palette (and teleported widgets) pick it up, and
|
|
13
|
+
// re-assert the root font-size while the admin is mounted. The admin is entirely rem-based and assumes
|
|
14
|
+
// the browser-default root; a consumer that sets `html { font-size: … }` on its public site would
|
|
15
|
+
// otherwise rescale the whole panel (rem resolves against the shared root, which no admin-side selector
|
|
16
|
+
// can override). An inline style beats a consumer stylesheet rule in the cascade without needing
|
|
17
|
+
// `!important`; `initial` restores the user-agent default (respecting the visitor's own font-size
|
|
18
|
+
// preference). unhead removes both attributes when the admin layout unmounts, so the public site keeps
|
|
19
|
+
// its own root. In dev the style guard also strips the consumer sheet outright (see
|
|
20
|
+
// admin-style-guard.client.ts); this reassert is the production-safe half of the defense.
|
|
21
|
+
// No admin page sets its own <title> today, so this fallback is the only one every route gets — a
|
|
22
|
+
// per-page useHead still wins (titleTemplate only fires when a title is actually set).
|
|
23
|
+
useHead(() => ({
|
|
24
|
+
title: 'Kestrel',
|
|
25
|
+
titleTemplate: (title) => (title && title !== 'Kestrel' ? `${title} · Kestrel` : 'Kestrel'),
|
|
26
|
+
htmlAttrs: { 'data-theme': theme.value, style: 'font-size: initial' },
|
|
27
|
+
}))
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<div class="admin" :class="{ 'admin--rail-collapsed': collapsed }">
|
|
32
|
+
<aside v-if="authenticated" class="admin__rail">
|
|
33
|
+
<div class="rail__head">
|
|
34
|
+
<NuxtLink to="/admin" class="rail__brand" aria-label="Kestrel">
|
|
35
|
+
<UiBrand />
|
|
36
|
+
<span class="rail__brand-word rail__label">kestrel</span>
|
|
37
|
+
</NuxtLink>
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
class="rail__toggle"
|
|
41
|
+
:aria-label="collapsed ? t('a11y.expandSidebar') : t('a11y.collapseSidebar')"
|
|
42
|
+
@click="toggleRail"
|
|
43
|
+
>
|
|
44
|
+
<UiIcon :name="collapsed ? 'panel-left-open' : 'panel-left-close'" size="1.25rem" />
|
|
45
|
+
</button>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div class="rail__nav">
|
|
49
|
+
<NuxtLink
|
|
50
|
+
to="/admin"
|
|
51
|
+
class="rail__item rail__item--dashboard"
|
|
52
|
+
:title="collapsed ? t('nav.dashboard') : undefined"
|
|
53
|
+
>
|
|
54
|
+
<UiIcon name="home" class="rail__icon" size="1.25rem" />
|
|
55
|
+
<span class="rail__label">{{ t('nav.dashboard') }}</span>
|
|
56
|
+
</NuxtLink>
|
|
57
|
+
|
|
58
|
+
<AdminNav />
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div class="rail__foot">
|
|
62
|
+
<AdminAccount />
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
class="rail__item"
|
|
66
|
+
:title="collapsed ? (theme === 'dark' ? t('theme.light') : t('theme.dark')) : undefined"
|
|
67
|
+
:aria-label="theme === 'dark' ? t('a11y.toLight') : t('a11y.toDark')"
|
|
68
|
+
@click="toggleTheme"
|
|
69
|
+
>
|
|
70
|
+
<UiIcon :name="theme === 'dark' ? 'sun' : 'moon'" class="rail__icon" size="1.25rem" />
|
|
71
|
+
<span class="rail__label">{{ theme === 'dark' ? t('theme.light') : t('theme.dark') }}</span>
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
</aside>
|
|
75
|
+
|
|
76
|
+
<main class="admin__main"><slot /></main>
|
|
77
|
+
<UiToasts />
|
|
78
|
+
</div>
|
|
79
|
+
</template>
|
|
80
|
+
|
|
81
|
+
<style lang="scss">
|
|
82
|
+
.admin {
|
|
83
|
+
display: flex;
|
|
84
|
+
align-items: flex-start;
|
|
85
|
+
min-height: 100svh;
|
|
86
|
+
background: var(--color-bg);
|
|
87
|
+
|
|
88
|
+
&__rail {
|
|
89
|
+
position: sticky;
|
|
90
|
+
top: 0;
|
|
91
|
+
flex: 0 0 var(--rail-width);
|
|
92
|
+
width: var(--rail-width);
|
|
93
|
+
height: 100svh;
|
|
94
|
+
display: flex;
|
|
95
|
+
flex-direction: column;
|
|
96
|
+
background: var(--color-rail-bg);
|
|
97
|
+
border-right: 1px solid var(--color-border);
|
|
98
|
+
transition:
|
|
99
|
+
flex-basis var(--motion-base) var(--ease-standard),
|
|
100
|
+
width var(--motion-base) var(--ease-standard);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// App-shell: the main column is locked to the viewport height and never scrolls itself — each page
|
|
104
|
+
// (and, in the editor, each of its panes) owns its own scroll region instead.
|
|
105
|
+
&__main {
|
|
106
|
+
flex: 1 1 auto;
|
|
107
|
+
min-width: 0;
|
|
108
|
+
height: 100svh;
|
|
109
|
+
overflow: hidden;
|
|
110
|
+
display: flex;
|
|
111
|
+
flex-direction: column;
|
|
112
|
+
padding: var(--space-4);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Scroll-ownership convention — every admin page follows it. The page section handed this
|
|
116
|
+
// `flex:1 1 auto; min-height:0` is a flex column that NEVER scrolls itself (overflow:hidden); it keeps
|
|
117
|
+
// its header/toolbar fixed (flex:0 0 auto) and delegates vertical scroll to exactly ONE inner region
|
|
118
|
+
// (flex:1 1 auto; min-height:0; overflow:auto). One unbroken min-height:0 chain reaches that region, so
|
|
119
|
+
// nothing else clips (inline popovers stay visible). Those regions, by page: the list table
|
|
120
|
+
// (.list__scroll), the flat/blocks editor body (.editor__flat / .editor3 panes), the media grid
|
|
121
|
+
// (.media-library__items) and the references table (.refs__scroll).
|
|
122
|
+
&__main > * {
|
|
123
|
+
flex: 1 1 auto;
|
|
124
|
+
min-height: 0;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Collapsed rail: narrow to icon-only width.
|
|
128
|
+
&--rail-collapsed &__rail {
|
|
129
|
+
flex-basis: var(--rail-width-collapsed);
|
|
130
|
+
width: var(--rail-width-collapsed);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.rail {
|
|
135
|
+
&__head {
|
|
136
|
+
display: flex;
|
|
137
|
+
align-items: center;
|
|
138
|
+
justify-content: space-between;
|
|
139
|
+
gap: var(--space-2);
|
|
140
|
+
padding: var(--space-4) var(--space-3);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
&__brand {
|
|
144
|
+
display: inline-flex;
|
|
145
|
+
align-items: center;
|
|
146
|
+
gap: var(--space-2);
|
|
147
|
+
min-width: 0;
|
|
148
|
+
color: var(--color-text);
|
|
149
|
+
text-decoration: none;
|
|
150
|
+
font-weight: var(--weight-bold);
|
|
151
|
+
border-radius: var(--radius-sm);
|
|
152
|
+
|
|
153
|
+
&:focus-visible {
|
|
154
|
+
outline: 2px solid var(--color-focus);
|
|
155
|
+
outline-offset: -2px;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
&__toggle {
|
|
160
|
+
display: inline-flex;
|
|
161
|
+
align-items: center;
|
|
162
|
+
justify-content: center;
|
|
163
|
+
flex-shrink: 0;
|
|
164
|
+
padding: var(--space-1);
|
|
165
|
+
border: 0;
|
|
166
|
+
border-radius: var(--radius-sm);
|
|
167
|
+
background: transparent;
|
|
168
|
+
color: var(--color-text-muted);
|
|
169
|
+
cursor: pointer;
|
|
170
|
+
|
|
171
|
+
&:hover {
|
|
172
|
+
background: var(--color-hover);
|
|
173
|
+
color: var(--color-text);
|
|
174
|
+
}
|
|
175
|
+
&:focus-visible {
|
|
176
|
+
outline: 2px solid var(--color-focus);
|
|
177
|
+
outline-offset: -2px;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
&__nav {
|
|
182
|
+
flex: 1 1 auto;
|
|
183
|
+
overflow-y: auto;
|
|
184
|
+
display: flex;
|
|
185
|
+
flex-direction: column;
|
|
186
|
+
gap: var(--space-1);
|
|
187
|
+
padding: var(--space-2) var(--space-3);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Shared item used by the static links, the collection links (AdminNav) and Sign out.
|
|
191
|
+
&__item {
|
|
192
|
+
display: flex;
|
|
193
|
+
align-items: center;
|
|
194
|
+
gap: var(--space-3);
|
|
195
|
+
width: 100%;
|
|
196
|
+
padding: var(--space-2) var(--space-3);
|
|
197
|
+
border: 0;
|
|
198
|
+
border-radius: var(--radius-md);
|
|
199
|
+
background: transparent;
|
|
200
|
+
color: var(--color-text);
|
|
201
|
+
font: inherit;
|
|
202
|
+
font-size: var(--text-sm);
|
|
203
|
+
text-align: left;
|
|
204
|
+
text-decoration: none;
|
|
205
|
+
cursor: pointer;
|
|
206
|
+
|
|
207
|
+
&:hover {
|
|
208
|
+
background: var(--color-hover);
|
|
209
|
+
}
|
|
210
|
+
&:focus-visible {
|
|
211
|
+
outline: 2px solid var(--color-focus);
|
|
212
|
+
outline-offset: -2px;
|
|
213
|
+
}
|
|
214
|
+
// Calm active state: a neutral fill (not a tinted pill) with the icon carrying a restrained accent.
|
|
215
|
+
&.router-link-active {
|
|
216
|
+
background: var(--color-active, var(--color-surface-2));
|
|
217
|
+
color: var(--color-text);
|
|
218
|
+
font-weight: var(--weight-medium);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
&__item.router-link-active &__icon {
|
|
223
|
+
color: var(--color-primary);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
&__icon {
|
|
227
|
+
flex-shrink: 0;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
&__label {
|
|
231
|
+
flex: 1 1 auto;
|
|
232
|
+
min-width: 0;
|
|
233
|
+
overflow: hidden;
|
|
234
|
+
text-overflow: ellipsis;
|
|
235
|
+
white-space: nowrap;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
&__foot {
|
|
239
|
+
padding: var(--space-3);
|
|
240
|
+
border-top: 1px solid var(--color-border);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Collapsed state: hide labels (kept in the DOM for a11y + tests), center icons.
|
|
246
|
+
.admin--rail-collapsed {
|
|
247
|
+
.rail__label {
|
|
248
|
+
position: absolute;
|
|
249
|
+
width: 1px;
|
|
250
|
+
height: 1px;
|
|
251
|
+
padding: 0;
|
|
252
|
+
margin: -1px;
|
|
253
|
+
overflow: hidden;
|
|
254
|
+
clip: rect(0, 0, 0, 0);
|
|
255
|
+
white-space: nowrap;
|
|
256
|
+
border: 0;
|
|
257
|
+
}
|
|
258
|
+
.rail__item {
|
|
259
|
+
justify-content: center;
|
|
260
|
+
gap: 0;
|
|
261
|
+
}
|
|
262
|
+
.rail__head {
|
|
263
|
+
flex-direction: column;
|
|
264
|
+
align-items: center;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
</style>
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { resolveLocalized } from '../../../../../ui/app/utils/localized'
|
|
3
|
+
import type { EditorExpose } from '../../../utils/editor-expose'
|
|
4
|
+
import type { BatchDeleteReport } from '../../../utils/collection-ops'
|
|
5
|
+
|
|
6
|
+
definePageMeta({ layout: 'admin', middleware: 'admin-auth', key: (route) => route.fullPath })
|
|
7
|
+
|
|
8
|
+
const route = useRoute()
|
|
9
|
+
const collection = route.params.collection as string
|
|
10
|
+
const id = route.params.id as string
|
|
11
|
+
// Content locale + translation group flow from the URL (set by the LocaleBar). The page `key` is the
|
|
12
|
+
// full path, so switching locale remounts the editor cleanly with the new locale/group.
|
|
13
|
+
const localeParam = computed(() => (typeof route.query.locale === 'string' ? route.query.locale.trim() || undefined : undefined))
|
|
14
|
+
const group = computed(() => (typeof route.query.group === 'string' ? route.query.group.trim() || undefined : undefined))
|
|
15
|
+
// Returning to the list keeps the locale we were editing in, so a multilingual workflow stays in context.
|
|
16
|
+
const listPath = computed(() => `/admin/${collection}${localeParam.value ? `?locale=${localeParam.value}` : ''}`)
|
|
17
|
+
|
|
18
|
+
const { t, lang } = useT()
|
|
19
|
+
const toast = useToast()
|
|
20
|
+
// Resolve the collection's display labels so the header reads "New Page" / "Posts", not the raw
|
|
21
|
+
// (often plural) route param. Non-critical chrome — a failed load falls back to the param.
|
|
22
|
+
const { load: loadCollections } = useCollections()
|
|
23
|
+
const def = (await loadCollections().catch(() => [])).find((c) => c.name === collection) ?? null
|
|
24
|
+
const singular = computed(() => resolveLocalized(def?.label?.singular, lang.value) ?? collection)
|
|
25
|
+
const plural = computed(() => resolveLocalized(def?.label?.plural, lang.value) ?? collection)
|
|
26
|
+
// The "create" heading prefers the collection's complete per-locale phrase (same one the list button
|
|
27
|
+
// uses), so a German title reads "Neue Seite" / "Neuer Beitrag" — never "Neu: …".
|
|
28
|
+
const newTitle = computed(() => resolveLocalized(def?.label?.new, lang.value) ?? t('editor.newRecord', { collection: singular.value }))
|
|
29
|
+
// The header (where Delete also lives) drives Save by submitting the editor's form via its id, and reads
|
|
30
|
+
// the editor's in-flight `saving` for the button state. `dirty` powers the unsaved-changes guard.
|
|
31
|
+
const EDITOR_FORM_ID = 'record-editor'
|
|
32
|
+
const editorRef = ref<EditorExpose | null>(null)
|
|
33
|
+
const saving = computed(() => editorRef.value?.saving ?? false)
|
|
34
|
+
// An id means nothing to an editor, so the heading shows the record's own title when it has one. A new (or
|
|
35
|
+
// still untitled) record has nothing but its id, and the generic phrase is the only thing that reads well.
|
|
36
|
+
const recordName = computed(() => editorRef.value?.recordTitle?.trim() ?? '')
|
|
37
|
+
const hasRecordTitle = computed(() => id !== 'new' && recordName.value !== '')
|
|
38
|
+
const heading = computed(() =>
|
|
39
|
+
id === 'new'
|
|
40
|
+
? newTitle.value
|
|
41
|
+
: hasRecordTitle.value
|
|
42
|
+
? recordName.value
|
|
43
|
+
: t('editor.editRecord', { collection: singular.value, id }),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
// Open the record's real public URL in a new tab — the live preview (drafts included, served to the
|
|
47
|
+
// authenticated admin by the public render path). Withheld until there's a saved, routable page.
|
|
48
|
+
function openPreview() {
|
|
49
|
+
const url = editorRef.value?.previewUrl
|
|
50
|
+
if (url) window.open(url, '_blank', 'noopener,noreferrer')
|
|
51
|
+
}
|
|
52
|
+
const skipGuard = ref(false)
|
|
53
|
+
|
|
54
|
+
// Delete flows through the shared batch op + the same confirm dialog the list uses, so the delete logic
|
|
55
|
+
// lives ONCE. On success the composable's onChanged navigates back to the list (bypassing the dirty guard).
|
|
56
|
+
const { busy: deleting, error: deleteError, previewDelete, confirmDelete: runDelete } = useCollectionOps(collection, async () => {
|
|
57
|
+
skipGuard.value = true
|
|
58
|
+
await navigateTo(listPath.value)
|
|
59
|
+
})
|
|
60
|
+
const deleteOpen = ref(false)
|
|
61
|
+
const deleteReport = ref<BatchDeleteReport | null>(null)
|
|
62
|
+
|
|
63
|
+
function onSaved(record: unknown) {
|
|
64
|
+
toast.success(t('toast.saved'))
|
|
65
|
+
// Save only saves — it does not leave the editor (navigation is the back link / rail's job). The one
|
|
66
|
+
// exception: a brand-new record switches to its own editor URL so further saves update it (id is now
|
|
67
|
+
// known) instead of POSTing a duplicate.
|
|
68
|
+
if (id === 'new') {
|
|
69
|
+
const newId = (record as { id?: number | string } | null)?.id
|
|
70
|
+
if (newId != null) {
|
|
71
|
+
skipGuard.value = true
|
|
72
|
+
return navigateTo(`/admin/${collection}/${newId}${localeParam.value ? `?locale=${localeParam.value}` : ''}`)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Guard unsaved edits on BOTH route-record changes (→ list) AND same-record param/query nav (the
|
|
78
|
+
// LocaleBar's edit-sibling / create-translation links reuse this `[id]` record → onBeforeRouteUpdate).
|
|
79
|
+
useUnsavedGuard(() => editorRef.value?.dirty ?? false, () => t('editor.discardConfirm'), () => skipGuard.value)
|
|
80
|
+
|
|
81
|
+
function toList() {
|
|
82
|
+
return navigateTo(listPath.value)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Open the confirm dialog, fetching the referrer aggregate for its warning. Best-effort: a failed lookup
|
|
86
|
+
// still opens the dialog with a bare summary (deleting is never blocked on the warning).
|
|
87
|
+
async function onDelete() {
|
|
88
|
+
deleteReport.value = null
|
|
89
|
+
deleteOpen.value = true
|
|
90
|
+
try {
|
|
91
|
+
deleteReport.value = await previewDelete([Number(id)])
|
|
92
|
+
} catch {
|
|
93
|
+
// The referrer lookup FAILED — mark the report unverified (checked:false) so the dialog shows the
|
|
94
|
+
// "references could not be verified" caution instead of a reassuring referencedCount:0 (which would
|
|
95
|
+
// read as a confirmed-safe delete).
|
|
96
|
+
deleteReport.value = { count: 1, referencedCount: 0, referenced: [], checked: false }
|
|
97
|
+
deleteError.value = null
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async function confirmDelete() {
|
|
101
|
+
try {
|
|
102
|
+
await runDelete([Number(id)])
|
|
103
|
+
toast.success(t('toast.deleted'))
|
|
104
|
+
deleteOpen.value = false
|
|
105
|
+
// navigation back to the list is the composable's onChanged
|
|
106
|
+
} catch {
|
|
107
|
+
// deleteError stays surfaced in the dialog
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
</script>
|
|
111
|
+
|
|
112
|
+
<template>
|
|
113
|
+
<section class="record">
|
|
114
|
+
<div class="record__head">
|
|
115
|
+
<NuxtLink :to="listPath" class="record__back">
|
|
116
|
+
<UiIcon name="arrow-left" :size="16" />
|
|
117
|
+
<span>{{ t('editor.back', { collection: plural }) }}</span>
|
|
118
|
+
</NuxtLink>
|
|
119
|
+
<h1 class="record__title" :class="{ 'record__title--generic': !hasRecordTitle }">{{ heading }}</h1>
|
|
120
|
+
<div class="record__actions">
|
|
121
|
+
<UiButton type="button" variant="ghost" size="sm" icon="undo" :disabled="saving || !editorRef?.canUndo" :title="t('history.undo')" :aria-label="t('history.undo')" @click="editorRef?.undo()" />
|
|
122
|
+
<UiButton type="button" variant="ghost" size="sm" icon="redo" :disabled="saving || !editorRef?.canRedo" :title="t('history.redo')" :aria-label="t('history.redo')" @click="editorRef?.redo()" />
|
|
123
|
+
<UiButton v-if="editorRef?.previewUrl" type="button" variant="ghost" size="sm" icon="external-link" :title="t('editor.openInNewTab')" :aria-label="t('editor.openInNewTab')" @click="openPreview" />
|
|
124
|
+
<UiButton type="button" variant="secondary" size="sm" icon="x" :disabled="saving" @click="toList">{{ t('common.cancel') }}</UiButton>
|
|
125
|
+
<UiButton v-if="id !== 'new'" variant="danger" size="sm" icon="trash" :loading="deleting" @click="onDelete">{{ t('common.delete') }}</UiButton>
|
|
126
|
+
<UiButton type="submit" :form="EDITOR_FORM_ID" variant="primary" size="sm" icon="check" :loading="saving">{{ t('common.save') }}</UiButton>
|
|
127
|
+
<EditorStatus class="record__ampel" :dirty="editorRef?.dirty ?? false" :saving="saving" :has-status="editorRef?.hasStatus ?? false" :status="editorRef?.status" :saved-status="editorRef?.savedStatus" :page-like="editorRef?.pageLike ?? false" :live="editorRef?.live" />
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
<CollectionEditor ref="editorRef" :collection="collection" :id="id" :form-id="EDITOR_FORM_ID" :locale-param="localeParam" :group="group" :actions="false" @saved="onSaved" />
|
|
131
|
+
<CollectionDeleteDialog
|
|
132
|
+
:open="deleteOpen"
|
|
133
|
+
:report="deleteReport"
|
|
134
|
+
:busy="deleting"
|
|
135
|
+
:error="deleteError"
|
|
136
|
+
@update:open="deleteOpen = $event"
|
|
137
|
+
@confirm="confirmDelete"
|
|
138
|
+
/>
|
|
139
|
+
</section>
|
|
140
|
+
</template>
|
|
141
|
+
|
|
142
|
+
<style lang="scss">
|
|
143
|
+
.record {
|
|
144
|
+
display: flex;
|
|
145
|
+
flex-direction: column;
|
|
146
|
+
gap: var(--space-4);
|
|
147
|
+
// The record shell stays fixed; the editor's panes own the scroll (see the admin layout).
|
|
148
|
+
overflow: hidden;
|
|
149
|
+
|
|
150
|
+
&__back {
|
|
151
|
+
display: inline-flex;
|
|
152
|
+
align-items: center;
|
|
153
|
+
gap: var(--space-1);
|
|
154
|
+
flex: 0 0 auto;
|
|
155
|
+
font-size: var(--text-sm);
|
|
156
|
+
color: var(--color-text-muted);
|
|
157
|
+
text-decoration: none;
|
|
158
|
+
|
|
159
|
+
&:hover {
|
|
160
|
+
color: var(--color-text);
|
|
161
|
+
}
|
|
162
|
+
span {
|
|
163
|
+
text-transform: capitalize;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// Back link, heading and actions share ONE row so the editor keeps the vertical space for its panes.
|
|
167
|
+
&__head {
|
|
168
|
+
flex: 0 0 auto;
|
|
169
|
+
display: flex;
|
|
170
|
+
align-items: center;
|
|
171
|
+
gap: var(--space-3);
|
|
172
|
+
flex-wrap: wrap;
|
|
173
|
+
}
|
|
174
|
+
&__actions {
|
|
175
|
+
display: flex;
|
|
176
|
+
align-items: center;
|
|
177
|
+
gap: var(--space-2);
|
|
178
|
+
margin-left: auto;
|
|
179
|
+
}
|
|
180
|
+
// The status Ampel sits far-right, divided from the action buttons.
|
|
181
|
+
&__ampel {
|
|
182
|
+
margin-inline-start: var(--space-1);
|
|
183
|
+
padding-inline-start: var(--space-3);
|
|
184
|
+
border-inline-start: 1px solid var(--color-border);
|
|
185
|
+
}
|
|
186
|
+
&__title {
|
|
187
|
+
font-size: var(--text-xl);
|
|
188
|
+
font-weight: var(--weight-bold);
|
|
189
|
+
// A long page title must not push the actions off the row.
|
|
190
|
+
min-width: 0;
|
|
191
|
+
overflow: hidden;
|
|
192
|
+
text-overflow: ellipsis;
|
|
193
|
+
white-space: nowrap;
|
|
194
|
+
|
|
195
|
+
// Only the generic "Edit {collection} #{id}" / "New {collection}" phrase is title-cased — a real record
|
|
196
|
+
// title is shown verbatim (capitalize would mangle e.g. "iPhone").
|
|
197
|
+
&--generic {
|
|
198
|
+
text-transform: capitalize;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
</style>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { resolveLocalized } from '../../../../../ui/app/utils/localized'
|
|
3
|
+
|
|
4
|
+
// Key on collection + locale ONLY (not the full path): a collection- or locale-change still remounts (which
|
|
5
|
+
// resets committed filter/draft/page/selection from the clean target URL), but a sort/page/perPage/filter
|
|
6
|
+
// query change must NOT remount — CollectionList derives that state from the URL in place, keeping the filter
|
|
7
|
+
// panel open and focus intact. ([id].vue deliberately keeps `route.fullPath` — it needs locale/group/id.)
|
|
8
|
+
definePageMeta({
|
|
9
|
+
layout: 'admin',
|
|
10
|
+
middleware: 'admin-auth',
|
|
11
|
+
key: (route) => `${route.params.collection}::${typeof route.query.locale === 'string' ? route.query.locale : ''}`,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const route = useRoute()
|
|
15
|
+
const collection = route.params.collection as string
|
|
16
|
+
const localeParam = computed(() => (typeof route.query.locale === 'string' ? route.query.locale.trim() || undefined : undefined))
|
|
17
|
+
const { primary } = useContentLocales()
|
|
18
|
+
|
|
19
|
+
const { t, lang } = useT()
|
|
20
|
+
const { load } = useCollections()
|
|
21
|
+
const collections = await load()
|
|
22
|
+
const def = collections.find((c) => c.name === collection) ?? null
|
|
23
|
+
// The content locale the list browses: the ?locale or the primary, for a translatable collection only.
|
|
24
|
+
const listLocale = computed(() => (def?.translatable ? (localeParam.value || primary) : undefined))
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<section class="collection">
|
|
29
|
+
<p v-if="!def" class="collection__missing">{{ t('collection.unknown', { name: collection }) }}</p>
|
|
30
|
+
|
|
31
|
+
<template v-else-if="def.mode === 'single'">
|
|
32
|
+
<SingletonEditor :collection="collection" :title="resolveLocalized(def.label?.singular, lang) ?? collection" :locale-param="localeParam" />
|
|
33
|
+
</template>
|
|
34
|
+
|
|
35
|
+
<template v-else>
|
|
36
|
+
<h1 class="collection__title">{{ resolveLocalized(def.label?.plural, lang) ?? collection }}</h1>
|
|
37
|
+
<CollectionList :schema="def" :locale="listLocale" />
|
|
38
|
+
</template>
|
|
39
|
+
</section>
|
|
40
|
+
</template>
|
|
41
|
+
|
|
42
|
+
<style lang="scss">
|
|
43
|
+
// Non-scrolling passthrough: hands its bounded height to whichever branch renders (the singleton
|
|
44
|
+
// editor, or the list title + CollectionList). It never scrolls itself — the inner region does.
|
|
45
|
+
.collection {
|
|
46
|
+
display: flex;
|
|
47
|
+
flex-direction: column;
|
|
48
|
+
gap: var(--space-5);
|
|
49
|
+
min-height: 0;
|
|
50
|
+
overflow: hidden;
|
|
51
|
+
|
|
52
|
+
&__title {
|
|
53
|
+
flex: 0 0 auto;
|
|
54
|
+
font-size: var(--text-xl);
|
|
55
|
+
font-weight: var(--weight-bold);
|
|
56
|
+
text-transform: capitalize;
|
|
57
|
+
}
|
|
58
|
+
&__missing {
|
|
59
|
+
color: var(--color-text-muted);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
</style>
|