@michaelthielemann/kestrel 3.0.0 → 4.0.0
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/README.md +35 -110
- package/kestrel.config.ts +1 -1
- package/layers/access/server/middleware/00.ip-allowlist.ts +3 -18
- package/layers/access/server/middleware/access-guard.ts +25 -23
- package/layers/access/server/types.d.ts +1 -2
- package/layers/admin/app/components/AdminAccount.vue +1 -1
- package/layers/admin/app/components/BlockFields.vue +1 -2
- package/layers/admin/app/components/BlockPreview.vue +17 -5
- package/layers/admin/app/components/BlocksBody.vue +1 -1
- package/layers/admin/app/components/CollectionEditor.vue +35 -16
- package/layers/admin/app/components/CollectionList.vue +34 -3
- package/layers/admin/app/components/CollectionListBulkBar.vue +17 -3
- package/layers/admin/app/components/CollectionListFilterPanel.vue +1 -1
- package/layers/admin/app/components/CollectionListPager.vue +1 -1
- package/layers/admin/app/components/CollectionListTable.vue +41 -2
- package/layers/admin/app/components/EditorStatus.vue +8 -7
- package/layers/admin/app/components/LocaleBar.vue +3 -1
- package/layers/admin/app/components/PageFields.vue +2 -4
- package/layers/admin/app/components/SeoFields.vue +2 -4
- package/layers/admin/app/components/SingletonEditor.vue +3 -2
- package/layers/admin/app/composables/useAuth.ts +3 -3
- package/layers/admin/app/composables/useBlockTree.ts +1 -1
- package/layers/admin/app/composables/useBlocks.ts +1 -1
- package/layers/admin/app/composables/useCollectionOps.ts +45 -8
- package/layers/admin/app/composables/useCollections.ts +1 -2
- package/layers/admin/app/composables/useEditForm.ts +35 -17
- package/layers/admin/app/composables/useLinkResolver.ts +1 -1
- package/layers/admin/app/composables/useListBatchActions.ts +20 -3
- package/layers/admin/app/composables/useListColumns.ts +1 -1
- package/layers/admin/app/composables/useListFilterDraft.ts +3 -2
- package/layers/admin/app/composables/useListRows.ts +7 -2
- package/layers/admin/app/composables/useListUrlState.ts +1 -1
- package/layers/admin/app/composables/usePublishStatus.ts +2 -2
- package/layers/admin/app/layouts/admin.vue +0 -1
- package/layers/admin/app/pages/admin/[collection]/[id].vue +5 -4
- package/layers/admin/app/pages/admin/index.vue +1 -1
- package/layers/admin/app/pages/admin/references.vue +2 -2
- package/layers/admin/app/utils/block-tree.ts +1 -1
- package/layers/admin/app/utils/collection-ops.ts +28 -3
- package/layers/admin/app/utils/edit-form.ts +34 -2
- package/layers/admin/app/utils/editor-expose.ts +2 -0
- package/layers/admin/app/utils/editor-form-context.ts +1 -2
- package/layers/admin/app/utils/field-keys.ts +1 -1
- package/layers/admin/app/utils/list-columns.ts +3 -2
- package/layers/admin/app/utils/list-query.ts +2 -2
- package/layers/admin/app/utils/populate-blocks.ts +2 -2
- package/layers/admin/app/utils/reauth.ts +5 -6
- package/layers/auth/server/plugins/01.register-auth-pipelines.ts +7 -0
- package/layers/collections/server/plugins/01.register.ts +9 -12
- package/layers/collections/server/plugins/02.register-relation-populate.ts +5 -7
- package/layers/{fields → core}/app/utils/field-factories.ts +1 -1
- package/layers/core/modules/auto-discovery/extract-block.ts +3 -3
- package/layers/core/modules/auto-discovery/index.ts +68 -11
- package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
- package/layers/core/modules/auto-discovery/scan.ts +77 -0
- package/layers/core/modules/auto-discovery/virtual.d.ts +5 -0
- package/layers/core/modules/component-namespace/index.ts +24 -0
- package/layers/core/modules/kestrel/index.ts +6 -1
- package/layers/core/modules/plugin-order/index.ts +69 -0
- package/layers/core/modules/plugin-order/plugin-order.ts +273 -0
- package/layers/core/nuxt.config.ts +1 -0
- package/layers/core/server/api/[...path].ts +98 -0
- package/layers/core/server/plugins/00.config.ts +12 -0
- package/layers/core/server/plugins/00.migrate.ts +1 -0
- package/layers/core/server/plugins/01.register-introspection-pipeline.ts +5 -0
- package/layers/core/server/plugins/01.register-openapi-pipeline.ts +5 -0
- package/layers/core/server/plugins/01.register-tooling-pipelines.ts +5 -0
- package/layers/core/server/plugins/02.schema-sync.ts +2 -7
- package/layers/core/server/plugins/04.outbox-worker.ts +31 -0
- package/layers/core/server/plugins/05.reindex-refs.ts +5 -0
- package/layers/core/server/routes/__kestrel/dashboard.get.ts +29 -0
- package/layers/core/server/tasks/db/migrate-module.ts +83 -0
- package/layers/core/server/tasks/db/migrate-revisions.ts +20 -0
- package/layers/core/server/tasks/db/migrate.ts +2 -6
- package/layers/core/server/utils/server-config.ts +66 -3
- package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +7 -0
- package/layers/fields/server/plugins/01.register-field-populate.ts +2 -2
- package/layers/media/app/components/KestrelImg.vue +3 -3
- package/layers/media/app/components/MediaLibrary.vue +2 -2
- package/layers/media/app/components/field/Media.vue +1 -1
- package/layers/media/app/composables/useMediaResolver.ts +1 -1
- package/layers/media/app/composables/useMediaUpload.ts +1 -1
- package/layers/media/app/composables/useMediaVariant.ts +2 -2
- package/layers/media/app/utils/ai-disclosure.ts +1 -1
- package/layers/media/app/utils/library.ts +1 -1
- package/layers/media/app/utils/ops.ts +1 -3
- package/layers/media/app/utils/picture-model.ts +2 -2
- package/layers/media/app/utils/variant-name.ts +1 -1
- package/layers/media/server/middleware/ondemand-variants.ts +6 -5
- package/layers/media/server/plugins/01.register-media-pipelines.ts +7 -0
- package/layers/media/server/plugins/02.register-media.ts +10 -7
- package/layers/media/server/plugins/04.variant-capture.ts +12 -8
- package/layers/media/server/plugins/05.media-cleanup.ts +5 -0
- package/layers/media/server/tasks/media/backfill.ts +3 -4
- package/layers/public/app/components/KestrelLink.vue +1 -1
- package/layers/public/app/pages/[...slug].vue +1 -1
- package/layers/public/app/pages/__kestrel/preview.vue +2 -2
- package/layers/public/app/utils/json-ld.ts +1 -1
- package/layers/public/app/utils/link-href.ts +1 -1
- package/layers/public/app/utils/page-head.ts +1 -1
- package/layers/public/modules/deploy-output/index.ts +4 -6
- package/layers/public/modules/prerender-routes/index.ts +4 -4
- package/layers/public/modules/prerender-routes/routes.ts +1 -1
- package/layers/public/modules/prune-media/index.ts +2 -2
- package/layers/public/server/delivery-live/serve.ts +111 -0
- package/layers/public/server/middleware/delivery-live-catchall.ts +7 -0
- package/layers/public/server/pipelines/index.ts +23 -0
- package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +14 -0
- package/layers/public/server/plugins/01.register-public-pipelines.ts +7 -0
- package/layers/public/server/plugins/02.register-links.ts +2 -3
- package/layers/public/server/plugins/03.redirects.ts +41 -23
- package/layers/public/server/plugins/05.plan-publish.ts +5 -0
- package/layers/public/server/plugins/zz.publish.ts +79 -24
- package/layers/public/server/routes/llms-full.txt.get.ts +5 -5
- package/layers/public/server/routes/llms.txt.get.ts +4 -2
- package/layers/public/server/routes/redirects.json.get.ts +2 -2
- package/layers/public/server/routes/robots.txt.get.ts +2 -0
- package/layers/public/server/routes/sitemap.xml.get.ts +16 -6
- package/layers/public/server/tasks/publish/run.ts +9 -4
- package/layers/public/server/utils/publish/render-live.ts +26 -0
- package/layers/ui/app/components/field/Choice.vue +1 -1
- package/layers/ui/app/components/field/Datetime.vue +1 -1
- package/layers/ui/app/components/field/Json.vue +1 -2
- package/layers/ui/app/components/field/Layout.vue +2 -4
- package/layers/ui/app/components/field/Link.vue +1 -1
- package/layers/ui/app/components/field/LinkInternalPicker.vue +3 -3
- package/layers/ui/app/components/field/Number.vue +2 -2
- package/layers/ui/app/components/field/Relation.vue +1 -1
- package/layers/ui/app/components/field/Repeater.vue +6 -8
- package/layers/ui/app/components/field/Slug.vue +2 -3
- package/layers/ui/app/components/field/Text.vue +1 -1
- package/layers/ui/app/components/ui/Button.vue +2 -2
- package/layers/ui/app/components/ui/ButtonGroup.vue +2 -2
- package/layers/ui/app/components/ui/Checkbox.vue +2 -2
- package/layers/ui/app/components/ui/Combobox.vue +5 -5
- package/layers/ui/app/components/ui/DatePicker.vue +2 -2
- package/layers/ui/app/components/ui/DateRangePicker.vue +2 -2
- package/layers/ui/app/components/ui/NumberInput.vue +4 -4
- package/layers/ui/app/components/ui/Richtext.vue +1 -1
- package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
- package/layers/ui/app/components/ui/Select.vue +2 -2
- package/layers/ui/app/components/ui/TextInput.vue +4 -4
- package/layers/ui/app/components/ui/Textarea.vue +2 -2
- package/layers/ui/app/components/ui/TimeInput.vue +2 -2
- package/layers/ui/app/components/ui/Toasts.vue +3 -3
- package/layers/ui/app/composables/useLinkField.ts +1 -1
- package/layers/ui/app/composables/useRepeater.ts +1 -1
- package/layers/ui/app/i18n/de.ts +7 -7
- package/layers/ui/app/i18n/en.ts +7 -39
- package/layers/ui/app/utils/field-component.ts +1 -1
- package/layers/ui/app/utils/field-registry.ts +1 -1
- package/layers/ui/app/utils/icons.ts +2 -6
- package/layers/ui/app/utils/localized.ts +1 -1
- package/nuxt.config.ts +1 -1
- package/package.json +53 -19
- package/scripts/consumer-template-ci.mjs +157 -0
- package/scripts/coverage-all.mjs +163 -0
- package/scripts/dashboard.mjs +89 -0
- package/scripts/hash-password.mjs +1 -1
- package/scripts/lib/coverage-model.mjs +51 -0
- package/scripts/port-graph.mjs +350 -0
- package/scripts/typecheck.mjs +11 -0
- package/templates/starter/app/blocks/Prose.vue +1 -1
- package/layers/access/server/utils/csrf.ts +0 -39
- package/layers/access/server/utils/grant-registry.ts +0 -36
- package/layers/access/server/utils/guard.ts +0 -44
- package/layers/access/server/utils/ip-allowlist.ts +0 -79
- package/layers/access/server/utils/policy.ts +0 -98
- package/layers/access/server/utils/public-resources.ts +0 -11
- package/layers/access/server/utils/render-context.ts +0 -41
- package/layers/access/server/utils/require-admin.ts +0 -15
- package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
- package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
- package/layers/auth/server/api/auth/login.post.ts +0 -38
- package/layers/auth/server/api/auth/logout.post.ts +0 -9
- package/layers/auth/server/api/auth/session.get.ts +0 -1
- package/layers/auth/server/utils/admin-credential.ts +0 -42
- package/layers/auth/server/utils/client-ip.ts +0 -88
- package/layers/auth/server/utils/login-throttle.ts +0 -126
- package/layers/auth/server/utils/password.ts +0 -52
- package/layers/auth/server/utils/session-cookie.ts +0 -44
- package/layers/auth/server/utils/session-epoch.ts +0 -96
- package/layers/auth/server/utils/session.ts +0 -88
- package/layers/collections/server/collections/pages.ts +0 -19
- package/layers/collections/server/utils/populate-relations.ts +0 -79
- package/layers/core/app/utils/bulk-actions.ts +0 -9
- package/layers/core/app/utils/filter-ops.ts +0 -98
- package/layers/core/app/utils/list-limits.ts +0 -27
- package/layers/core/app/utils/locale-path.ts +0 -12
- package/layers/core/app/utils/slugify.ts +0 -16
- package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
- package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
- package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
- package/layers/core/server/api/[collection]/[id].get.ts +0 -11
- package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
- package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
- package/layers/core/server/api/[collection]/index.get.ts +0 -24
- package/layers/core/server/api/[collection]/index.post.ts +0 -8
- package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
- package/layers/core/server/api/[collection]/index.put.ts +0 -24
- package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
- package/layers/core/server/api/[collection]/options.get.ts +0 -18
- package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
- package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
- package/layers/core/server/api/[collection]/translations.get.ts +0 -25
- package/layers/core/server/api/collections/[name].get.ts +0 -4
- package/layers/core/server/api/collections/index.get.ts +0 -3
- package/layers/core/server/api/references/broken.get.ts +0 -9
- package/layers/core/server/api/references/referrers.get.ts +0 -24
- package/layers/core/server/database/record-refs.ts +0 -20
- package/layers/core/server/plugins/03.record-refs.ts +0 -12
- package/layers/core/server/schema/bootstrap.ts +0 -41
- package/layers/core/server/schema/desired.ts +0 -99
- package/layers/core/server/schema/dev-sync.ts +0 -23
- package/layers/core/server/schema/dialect.ts +0 -61
- package/layers/core/server/schema/diff.ts +0 -110
- package/layers/core/server/schema/introspect.ts +0 -68
- package/layers/core/server/schema/model.ts +0 -61
- package/layers/core/server/schema/render-sqlite.ts +0 -89
- package/layers/core/server/schema/sync.ts +0 -201
- package/layers/core/server/utils/blocks.ts +0 -6
- package/layers/core/server/utils/collection-types.ts +0 -28
- package/layers/core/server/utils/crud.ts +0 -578
- package/layers/core/server/utils/db.ts +0 -26
- package/layers/core/server/utils/defineCollection.ts +0 -213
- package/layers/core/server/utils/duplicate.ts +0 -162
- package/layers/core/server/utils/field-layout.ts +0 -125
- package/layers/core/server/utils/filter-predicate.ts +0 -101
- package/layers/core/server/utils/http.ts +0 -50
- package/layers/core/server/utils/if-unmodified.ts +0 -13
- package/layers/core/server/utils/kestrel-config.ts +0 -451
- package/layers/core/server/utils/key-lock.ts +0 -33
- package/layers/core/server/utils/locale.ts +0 -53
- package/layers/core/server/utils/page-route.ts +0 -58
- package/layers/core/server/utils/page-slug.ts +0 -127
- package/layers/core/server/utils/picker.ts +0 -82
- package/layers/core/server/utils/populate.ts +0 -50
- package/layers/core/server/utils/prerender-memo.ts +0 -23
- package/layers/core/server/utils/read-capture.ts +0 -37
- package/layers/core/server/utils/record-ref-index.ts +0 -200
- package/layers/core/server/utils/registry.ts +0 -24
- package/layers/core/server/utils/resolve-scope.ts +0 -79
- package/layers/core/server/utils/seo.ts +0 -42
- package/layers/core/server/utils/serialize-collection.ts +0 -147
- package/layers/core/server/utils/sql.ts +0 -2
- package/layers/core/server/utils/storage.local.ts +0 -117
- package/layers/core/server/utils/storage.s3.ts +0 -197
- package/layers/core/server/utils/storage.ts +0 -90
- package/layers/core/server/utils/write-effects.ts +0 -40
- package/layers/core/server/utils/write-events.ts +0 -31
- package/layers/fields/app/utils/condition.ts +0 -71
- package/layers/fields/app/utils/define-block.ts +0 -13
- package/layers/fields/app/utils/field-constraints.ts +0 -17
- package/layers/fields/app/utils/field-meta.ts +0 -33
- package/layers/fields/app/utils/field-validate.ts +0 -75
- package/layers/fields/app/utils/field-value.ts +0 -7
- package/layers/fields/app/utils/richtext-links.ts +0 -48
- package/layers/fields/server/api/blocks/index.get.ts +0 -9
- package/layers/fields/server/field-registry/index.ts +0 -249
- package/layers/fields/server/field-registry/naming.ts +0 -10
- package/layers/fields/server/field-registry/sanitize.ts +0 -43
- package/layers/fields/server/field-registry/types.ts +0 -19
- package/layers/fields/server/utils/block-ids.ts +0 -43
- package/layers/fields/server/utils/block-populate.ts +0 -41
- package/layers/fields/server/utils/buildCollection.ts +0 -90
- package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
- package/layers/fields/server/utils/buildTable.ts +0 -113
- package/layers/fields/server/utils/conditional-required.ts +0 -25
- package/layers/fields/server/utils/defineBlock.ts +0 -86
- package/layers/fields/server/utils/defineFieldType.ts +0 -33
- package/layers/fields/server/utils/extract-refs.ts +0 -152
- package/layers/fields/server/utils/field-populate.ts +0 -68
- package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
- package/layers/media/server/api/media/[id].delete.ts +0 -18
- package/layers/media/server/api/media/[id].get.ts +0 -10
- package/layers/media/server/api/media/[id].patch.test.ts +0 -66
- package/layers/media/server/api/media/[id].patch.ts +0 -65
- package/layers/media/server/api/media/backfill.post.ts +0 -18
- package/layers/media/server/api/media/copy.post.ts +0 -7
- package/layers/media/server/api/media/delete.post.ts +0 -13
- package/layers/media/server/api/media/folders.post.ts +0 -18
- package/layers/media/server/api/media/index.get.ts +0 -15
- package/layers/media/server/api/media/index.post.ts +0 -173
- package/layers/media/server/api/media/library.get.ts +0 -17
- package/layers/media/server/api/media/move.post.ts +0 -7
- package/layers/media/server/api/media/rename.post.ts +0 -7
- package/layers/media/server/api/media/resolve.get.ts +0 -13
- package/layers/media/server/collections/media-settings.ts +0 -25
- package/layers/media/server/collections/media.ts +0 -43
- package/layers/media/server/database/folders.ts +0 -10
- package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
- package/layers/media/server/schema-tables/folders.ts +0 -4
- package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
- package/layers/media/server/utils/ai-signal-detect.ts +0 -155
- package/layers/media/server/utils/backfill.ts +0 -144
- package/layers/media/server/utils/derive.ts +0 -59
- package/layers/media/server/utils/folder-paths.ts +0 -42
- package/layers/media/server/utils/folders.ts +0 -12
- package/layers/media/server/utils/library.ts +0 -91
- package/layers/media/server/utils/media-cleanup.ts +0 -27
- package/layers/media/server/utils/media-enabled.ts +0 -23
- package/layers/media/server/utils/media-ops.ts +0 -118
- package/layers/media/server/utils/media-write.ts +0 -13
- package/layers/media/server/utils/naming.ts +0 -52
- package/layers/media/server/utils/ondemand.ts +0 -69
- package/layers/media/server/utils/persist-upload.ts +0 -62
- package/layers/media/server/utils/populate.ts +0 -47
- package/layers/media/server/utils/record.ts +0 -71
- package/layers/media/server/utils/relocate-ops.ts +0 -341
- package/layers/media/server/utils/relocate-plan.ts +0 -63
- package/layers/media/server/utils/resolve.ts +0 -98
- package/layers/media/server/utils/sanitize-svg.ts +0 -12
- package/layers/media/server/utils/sniff.ts +0 -68
- package/layers/media/server/utils/storage-relocate.ts +0 -139
- package/layers/media/server/utils/translations.ts +0 -19
- package/layers/media/server/utils/usages.ts +0 -66
- package/layers/media/server/utils/variant-capture.ts +0 -70
- package/layers/media/server/utils/variants.ts +0 -123
- package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
- package/layers/public/server/api/links/resolve.get.ts +0 -19
- package/layers/public/server/api/preview.get.ts +0 -28
- package/layers/public/server/api/preview.post.ts +0 -93
- package/layers/public/server/api/publish-status.get.ts +0 -58
- package/layers/public/server/api/publish.post.ts +0 -84
- package/layers/public/server/api/route.get.ts +0 -54
- package/layers/public/server/collections/redirects.ts +0 -75
- package/layers/public/server/collections/site.ts +0 -44
- package/layers/public/server/database/publish-deps.ts +0 -12
- package/layers/public/server/database/publish-status.ts +0 -19
- package/layers/public/server/schema-tables/publish-deps.ts +0 -4
- package/layers/public/server/schema-tables/publish-status.ts +0 -4
- package/layers/public/server/utils/link-resolve.ts +0 -38
- package/layers/public/server/utils/llms-full.ts +0 -125
- package/layers/public/server/utils/llms.ts +0 -52
- package/layers/public/server/utils/page-resolve.ts +0 -208
- package/layers/public/server/utils/populate-links.ts +0 -66
- package/layers/public/server/utils/preview-token.ts +0 -109
- package/layers/public/server/utils/publish/coalesce.ts +0 -22
- package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
- package/layers/public/server/utils/publish/deps.ts +0 -87
- package/layers/public/server/utils/publish/invalidation.ts +0 -225
- package/layers/public/server/utils/publish/pending.ts +0 -74
- package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
- package/layers/public/server/utils/publish/publish-status.ts +0 -72
- package/layers/public/server/utils/publish/publisher.ts +0 -362
- package/layers/public/server/utils/publish/queue.ts +0 -67
- package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
- package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
- package/layers/public/server/utils/publish/route-for-record.ts +0 -16
- package/layers/public/server/utils/publish/route-keys.ts +0 -13
- package/layers/public/server/utils/richtext-markdown.ts +0 -260
- package/layers/public/server/utils/site-url.ts +0 -40
- package/layers/public/server/utils/sitemap.ts +0 -99
|
@@ -3,10 +3,14 @@
|
|
|
3
3
|
// than overlaying it. Publish/Unpublish are schema-driven (only when the collection has a status).
|
|
4
4
|
// NOT a live region: the selection count is announced through the list's permanent .list__sr-status
|
|
5
5
|
// region; a v-if-toggled role="status" here would double up and stay silent on first show.
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
import { resolveLocalized } from '../../../ui/app/utils/localized'
|
|
7
|
+
import type { SerializedAction } from '@michaelthielemann/kestrel-core'
|
|
8
8
|
|
|
9
|
-
const {
|
|
9
|
+
const props = defineProps<{ count: number; hasStatus: boolean; busy: boolean; actions?: SerializedAction[] }>()
|
|
10
|
+
const emit = defineEmits<{ setStatus: [status: 'published' | 'draft']; delete: []; runAction: [action: SerializedAction]; clear: [] }>()
|
|
11
|
+
|
|
12
|
+
const { t, lang } = useT()
|
|
13
|
+
const actionLabel = (action: SerializedAction) => resolveLocalized(action.label, lang.value) ?? action.name
|
|
10
14
|
</script>
|
|
11
15
|
|
|
12
16
|
<template>
|
|
@@ -17,6 +21,16 @@ const { t } = useT()
|
|
|
17
21
|
<KestrelUiButton type="button" size="sm" variant="secondary" :disabled="busy" @click="emit('setStatus', 'published')">{{ t('list.bulkPublish') }}</KestrelUiButton>
|
|
18
22
|
<KestrelUiButton type="button" size="sm" variant="secondary" :disabled="busy" @click="emit('setStatus', 'draft')">{{ t('list.bulkUnpublish') }}</KestrelUiButton>
|
|
19
23
|
</template>
|
|
24
|
+
<KestrelUiButton
|
|
25
|
+
v-for="action in props.actions"
|
|
26
|
+
:key="action.name"
|
|
27
|
+
type="button"
|
|
28
|
+
size="sm"
|
|
29
|
+
variant="secondary"
|
|
30
|
+
:disabled="busy"
|
|
31
|
+
:data-action="action.name"
|
|
32
|
+
@click="emit('runAction', action)"
|
|
33
|
+
>{{ actionLabel(action) }}</KestrelUiButton>
|
|
20
34
|
<KestrelUiButton type="button" size="sm" variant="danger" :disabled="busy" @click="emit('delete')">{{ t('list.bulkDelete') }}</KestrelUiButton>
|
|
21
35
|
<KestrelUiButton type="button" size="sm" variant="ghost" @click="emit('clear')">{{ t('list.clearSelection') }}</KestrelUiButton>
|
|
22
36
|
</div>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type { FilterOp } from '
|
|
2
|
+
import type { FilterOp } from '@michaelthielemann/kestrel-core'
|
|
3
3
|
import type { ListFilterDraft } from '../composables/useListFilterDraft'
|
|
4
4
|
import { columnLabel } from '../utils/list-cell'
|
|
5
5
|
import type { ListColumn } from '../utils/list-columns'
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { PER_PAGE_OPTIONS } from '
|
|
2
|
+
import { PER_PAGE_OPTIONS } from '@michaelthielemann/kestrel-core/client'
|
|
3
3
|
|
|
4
4
|
const props = defineProps<{ page: number; totalPages: number; total: number; perPage: number }>()
|
|
5
5
|
const emit = defineEmits<{ 'update:page': [page: number]; 'update:perPage': [perPage: number] }>()
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { sortDirection } from '../utils/list-query'
|
|
3
3
|
import { cellDisplay, columnLabel, rowLabel as labelForRow } from '../utils/list-cell'
|
|
4
4
|
import type { ListColumn } from '../utils/list-columns'
|
|
5
|
+
import { resolveLocalized } from '../../../ui/app/utils/localized'
|
|
6
|
+
import type { SerializedAction } from '@michaelthielemann/kestrel-core'
|
|
5
7
|
|
|
6
8
|
const props = defineProps<{
|
|
7
9
|
rows: Record<string, unknown>[]
|
|
@@ -13,6 +15,8 @@ const props = defineProps<{
|
|
|
13
15
|
selected: Set<number>
|
|
14
16
|
allSelected: boolean
|
|
15
17
|
headerIndeterminate: boolean
|
|
18
|
+
/** Schema-driven row actions beyond the built-in edit/duplicate/delete. */
|
|
19
|
+
actions?: SerializedAction[]
|
|
16
20
|
}>()
|
|
17
21
|
const emit = defineEmits<{
|
|
18
22
|
sort: [key: string]
|
|
@@ -20,13 +24,15 @@ const emit = defineEmits<{
|
|
|
20
24
|
toggleAll: [on: boolean]
|
|
21
25
|
duplicate: [id: number]
|
|
22
26
|
delete: [ids: number[]]
|
|
27
|
+
runAction: [action: SerializedAction, id: number]
|
|
23
28
|
}>()
|
|
24
29
|
|
|
25
|
-
const { t } = useT()
|
|
30
|
+
const { t, lang } = useT()
|
|
26
31
|
const { locales } = useContentLocales()
|
|
27
32
|
|
|
28
33
|
const colLabel = (col: ListColumn) => columnLabel(col, t)
|
|
29
34
|
const rowLabel = (row: Record<string, unknown>) => labelForRow(props.columns, row)
|
|
35
|
+
const actionLabel = (action: SerializedAction) => resolveLocalized(action.label, lang.value) ?? action.name
|
|
30
36
|
|
|
31
37
|
function arrow(field: string) {
|
|
32
38
|
const dir = sortDirection(props.sort, field)
|
|
@@ -99,12 +105,32 @@ function rowTranslations(row: Record<string, unknown>) {
|
|
|
99
105
|
<button type="button" class="list__action-btn" :disabled="busy" :aria-label="t('list.rowDuplicate', { name: rowLabel(row) })" @click="emit('duplicate', Number(row.id))">
|
|
100
106
|
<KestrelUiIcon name="copy" :size="15" />
|
|
101
107
|
</button>
|
|
108
|
+
<button
|
|
109
|
+
v-for="action in actions"
|
|
110
|
+
:key="action.name"
|
|
111
|
+
type="button"
|
|
112
|
+
class="list__action-btn"
|
|
113
|
+
:disabled="busy"
|
|
114
|
+
:data-action="action.name"
|
|
115
|
+
:aria-label="`${actionLabel(action)}: ${rowLabel(row)}`"
|
|
116
|
+
@click="emit('runAction', action, Number(row.id))"
|
|
117
|
+
>
|
|
118
|
+
<KestrelUiIcon :name="action.icon ?? 'zap'" :size="15" />
|
|
119
|
+
</button>
|
|
102
120
|
<button type="button" class="list__action-btn list__action-btn--danger" :disabled="busy" :aria-label="t('list.rowDelete', { name: rowLabel(row) })" @click="emit('delete', [Number(row.id)])">
|
|
103
121
|
<KestrelUiIcon name="trash" :size="15" />
|
|
104
122
|
</button>
|
|
105
123
|
</div>
|
|
106
124
|
</td>
|
|
107
|
-
|
|
125
|
+
<!-- A quarantined row carries none of its normal fields — a single spanning cell replaces
|
|
126
|
+
the per-column cells rather than rendering blanks for data that doesn't exist. -->
|
|
127
|
+
<td v-if="row.$quarantined === true" :colspan="columns.length">
|
|
128
|
+
<span class="list__quarantine-badge">
|
|
129
|
+
<KestrelUiIcon name="triangle-alert" :size="14" role="img" :aria-label="t('list.quarantined')" />
|
|
130
|
+
{{ t('list.quarantined') }}
|
|
131
|
+
</span>
|
|
132
|
+
</td>
|
|
133
|
+
<td v-else v-for="c in columns" :key="c.key" :class="{ 'list__narrow-cell': c.type === 'translations' || c.type === 'deadRefs' }">
|
|
108
134
|
<span v-if="c.type === 'deadRefs'" class="list__deadrefs">
|
|
109
135
|
<KestrelUiIcon
|
|
110
136
|
v-if="row.$hasDeadRefs"
|
|
@@ -196,6 +222,19 @@ function rowTranslations(row: Record<string, unknown>) {
|
|
|
196
222
|
white-space: nowrap;
|
|
197
223
|
}
|
|
198
224
|
|
|
225
|
+
// Quarantine badge — icon + text (not color alone, WCAG 1.4.1), replacing a row's normal data cells.
|
|
226
|
+
&__quarantine-badge {
|
|
227
|
+
display: inline-flex;
|
|
228
|
+
align-items: center;
|
|
229
|
+
gap: var(--space-1);
|
|
230
|
+
padding: 2px var(--space-2);
|
|
231
|
+
border: 1px solid var(--color-danger);
|
|
232
|
+
border-radius: var(--radius-sm);
|
|
233
|
+
color: var(--color-danger);
|
|
234
|
+
font-size: var(--text-xs);
|
|
235
|
+
font-weight: var(--weight-medium);
|
|
236
|
+
}
|
|
237
|
+
|
|
199
238
|
// Dead-reference warning: an amber triangle, shown only on rows holding a stale reference.
|
|
200
239
|
&__deadrefs {
|
|
201
240
|
display: inline-flex;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// blue — saved but a Draft, so it is intentionally NOT on the live/static site
|
|
6
6
|
// green — saved and (for status collections) published, or just saved (no status field)
|
|
7
7
|
// RIGHT — the GENERATED state of this record's static page (pageLike collections only), read from
|
|
8
|
-
// `publish_status` via `/api/
|
|
8
|
+
// `publish_status` via `/api/publishStatus`:
|
|
9
9
|
// green — the route's last publish succeeded (the file is live; the tooltip names local vs S3)
|
|
10
10
|
// amber — published but no success row yet — a republish is in flight ("Generating…")
|
|
11
11
|
// neutral — this environment never produces the file (dev / static output off) → "Not built"
|
|
@@ -24,7 +24,7 @@ const props = defineProps<{
|
|
|
24
24
|
savedStatus?: string
|
|
25
25
|
/** Only pageLike collections produce a static page → only they get the right (live) lamp. */
|
|
26
26
|
pageLike?: boolean
|
|
27
|
-
/** The record's last publish outcome (from `/api/
|
|
27
|
+
/** The record's last publish outcome (from `/api/publishStatus`); null/absent while unknown. */
|
|
28
28
|
live?: PublishStatusData | null
|
|
29
29
|
}>()
|
|
30
30
|
const { t, lang } = useT()
|
|
@@ -62,6 +62,11 @@ const live = computed<{ tone: 'green' | 'red' | 'amber' | 'blue' | 'neutral'; wo
|
|
|
62
62
|
const s = props.live?.status ?? null
|
|
63
63
|
if (s === 'error')
|
|
64
64
|
return { tone: 'red', word: t('editorStatus.word.error'), detail: t('editorStatus.live.error'), when: updatedAtLabel.value, error: props.live?.error ?? '' }
|
|
65
|
+
// An environment that never generates (dev / static output off) cannot resolve any pending state, so an
|
|
66
|
+
// alarming "Outdated" would point at a problem no action here can fix — calm neutral, even when an old
|
|
67
|
+
// success row (e.g. a prod run against the same DB) exists.
|
|
68
|
+
if (props.live?.generates === false)
|
|
69
|
+
return { tone: 'neutral', word: t('editorStatus.word.notBuilt'), detail: t('editorStatus.live.notBuilt') }
|
|
65
70
|
if (s === 'success') {
|
|
66
71
|
// Saved after the last publish: the file IS live, but it is an older version of this record. Saving no
|
|
67
72
|
// longer republishes (ADR-0008), so this is the normal state of a page being worked on — amber, not red.
|
|
@@ -72,11 +77,7 @@ const live = computed<{ tone: 'green' | 'red' | 'amber' | 'blue' | 'neutral'; wo
|
|
|
72
77
|
const onS3 = (props.live?.target ?? props.live?.driver) === 's3'
|
|
73
78
|
return { tone: 'green', word: t('editorStatus.word.live'), detail: t(onS3 ? 'editorStatus.live.liveS3' : 'editorStatus.live.liveLocal'), when: updatedAtLabel.value }
|
|
74
79
|
}
|
|
75
|
-
//
|
|
76
|
-
// static output off) → a calm neutral "Not built". Unknown (undefined) → assume a publish is in flight.
|
|
77
|
-
if (props.live?.generates === false)
|
|
78
|
-
return { tone: 'neutral', word: t('editorStatus.word.notBuilt'), detail: t('editorStatus.live.notBuilt') }
|
|
79
|
-
// Never published, and a save no longer enqueues anything (ADR-0008) — so nothing is in flight and
|
|
80
|
+
// Never published, and a save does not enqueue anything (ADR-0008) — so nothing is in flight and
|
|
80
81
|
// nothing will be until someone presses Publish. Reporting progress here would point the user at a
|
|
81
82
|
// spinner instead of at the one action that changes it. With `publishOnSave` a save DOES republish, so
|
|
82
83
|
// there the in-flight reading is the right one.
|
|
@@ -41,7 +41,9 @@ const { t } = useT()
|
|
|
41
41
|
>{{ up(loc) }}<KestrelUiIcon name="pencil" :size="14" /></NuxtLink>
|
|
42
42
|
|
|
43
43
|
<span v-else-if="existingId(loc) !== null" class="locale-bar__item">
|
|
44
|
-
|
|
44
|
+
<!-- No `?locale=` here: the target id already IS a specific locale's row (useEditForm reads the
|
|
45
|
+
active locale off the loaded record, not the query, once an id is known). -->
|
|
46
|
+
<NuxtLink class="locale-bar__btn" :to="`/admin/${collection}/${existingId(loc)}`" :aria-label="t('localeBar.editLocale', { loc: up(loc) })">{{ up(loc) }}<KestrelUiIcon name="pencil" :size="14" /></NuxtLink>
|
|
45
47
|
<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)"><KestrelUiIcon name="copy" :size="14" /></button>
|
|
46
48
|
</span>
|
|
47
49
|
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import type { FieldDef } from '
|
|
3
|
-
import
|
|
4
|
-
import type { SeoMeta } from '../../../core/server/utils/seo'
|
|
5
|
-
|
|
2
|
+
import type { FieldDef, LayoutNode, SeoMeta } from '@michaelthielemann/kestrel-core'
|
|
3
|
+
import { slugify } from '@michaelthielemann/kestrel-core/client'
|
|
6
4
|
// The collection (page) field list — delegates to the shared <KestrelFieldLayout> renderer (rows / groups /
|
|
7
5
|
// widths, or one field per row when the collection declares no layout), shared by the flat-collection
|
|
8
6
|
// form and the 3-pane editor's root-selected pane so the wiring lives in exactly one place. pageLike
|
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { computed } from 'vue'
|
|
3
|
-
import
|
|
4
|
-
import type { FieldDef } from '
|
|
5
|
-
import { localePath } from '../../../core/app/utils/locale-path'
|
|
6
|
-
|
|
3
|
+
import { localePath } from '@michaelthielemann/kestrel-core/client'
|
|
4
|
+
import type { FieldDef, SeoMeta } from '@michaelthielemann/kestrel-core'
|
|
7
5
|
// The page-level SEO section: edit the meta title/description/noindex (the `seo` system column) with a
|
|
8
6
|
// live Google-result preview. Emits the whole merged SeoMeta so the parent routes it through setField.
|
|
9
7
|
const props = defineProps<{
|
|
@@ -34,9 +34,10 @@ useUnsavedGuard(() => editorRef.value?.dirty ?? false, () => t('editor.discardCo
|
|
|
34
34
|
<KestrelUiButton type="button" variant="ghost" size="sm" icon="undo" :disabled="saving || !editorRef?.canUndo" :title="t('history.undo')" :aria-label="t('history.undo')" @click="editorRef?.undo()" />
|
|
35
35
|
<KestrelUiButton type="button" variant="ghost" size="sm" icon="redo" :disabled="saving || !editorRef?.canRedo" :title="t('history.redo')" :aria-label="t('history.redo')" @click="editorRef?.redo()" />
|
|
36
36
|
<KestrelUiButton type="button" variant="ghost" size="sm" icon="external-link" :title="previewTitle" :aria-label="previewTitle" :loading="editorRef?.previewOpening" @click="editorRef?.openPreview()" />
|
|
37
|
-
|
|
37
|
+
<!-- A quarantined record is read-only: no Save, no Publish — there is nothing valid to write. -->
|
|
38
|
+
<KestrelUiButton v-if="!editorRef?.quarantined" type="submit" :form="EDITOR_FORM_ID" variant="primary" size="sm" icon="check" :loading="saving">{{ t('common.save') }}</KestrelUiButton>
|
|
38
39
|
<!-- Publishing is its own decision: Save persists, Publish writes the static page (ADR-0008). -->
|
|
39
|
-
<KestrelUiButton v-if="editorRef?.canPublish !== false" type="button" variant="secondary" size="sm" icon="upload" :loading="editorRef?.publishing" :disabled="saving" @click="editorRef?.publish()">{{ t('common.publish') }}</KestrelUiButton>
|
|
40
|
+
<KestrelUiButton v-if="editorRef?.canPublish !== false && !editorRef?.quarantined" type="button" variant="secondary" size="sm" icon="upload" :loading="editorRef?.publishing" :disabled="saving" @click="editorRef?.publish()">{{ t('common.publish') }}</KestrelUiButton>
|
|
40
41
|
<KestrelEditorStatus class="singleton__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" />
|
|
41
42
|
</div>
|
|
42
43
|
</div>
|
|
@@ -17,7 +17,7 @@ export function useAuth() {
|
|
|
17
17
|
// Fail closed: a transient session-endpoint failure (5xx, offline) must read as
|
|
18
18
|
// "not authenticated" so the route guard redirects to login instead of throwing an error page.
|
|
19
19
|
try {
|
|
20
|
-
const r = await $fetch<{ authenticated: boolean; exp?: number }>('/api/
|
|
20
|
+
const r = await $fetch<{ authenticated: boolean; exp?: number }>('/api/session')
|
|
21
21
|
state.value = { authenticated: r.authenticated, exp: r.exp ?? null, checked: true }
|
|
22
22
|
} catch {
|
|
23
23
|
state.value = { authenticated: false, exp: null, checked: true }
|
|
@@ -31,7 +31,7 @@ export function useAuth() {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
async function login(password: string) {
|
|
34
|
-
const r = await $fetch<{ ok: true; exp: number }>('/api/
|
|
34
|
+
const r = await $fetch<{ ok: true; exp: number }>('/api/login', {
|
|
35
35
|
method: 'POST',
|
|
36
36
|
body: { password },
|
|
37
37
|
})
|
|
@@ -48,7 +48,7 @@ export function useAuth() {
|
|
|
48
48
|
// The desired end state is "logged out" regardless of the response: an expired/rotated
|
|
49
49
|
// session makes the guarded logout endpoint 401, but the local session is still cleared.
|
|
50
50
|
try {
|
|
51
|
-
await $fetch('/api/
|
|
51
|
+
await $fetch('/api/logout', { method: 'POST' })
|
|
52
52
|
} catch {
|
|
53
53
|
// already invalid server-side — nothing to clear there
|
|
54
54
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ref, computed } from 'vue'
|
|
2
2
|
import type { Ref } from 'vue'
|
|
3
3
|
import { useEchoGuard } from '../../../ui/app/composables/useEchoGuard'
|
|
4
|
-
import type { SerializedBlock } from '
|
|
4
|
+
import type { SerializedBlock } from '@michaelthielemann/kestrel-core'
|
|
5
5
|
import {
|
|
6
6
|
type BlockRow,
|
|
7
7
|
findInTree,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SerializedBlock } from '
|
|
1
|
+
import type { SerializedBlock } from '@michaelthielemann/kestrel-core'
|
|
2
2
|
|
|
3
3
|
// Thin wrapper over the shared cached-resource loader; keeps the named `blocks` return.
|
|
4
4
|
export function useBlocks() {
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { ref, toValue, type MaybeRefOrGetter } from 'vue'
|
|
2
|
-
import { buildDeleteReport, type BatchAction, type BatchDeleteReport, type BulkResult, type ReferrerCounts } from '../utils/collection-ops'
|
|
2
|
+
import { buildDeleteReport, type BatchAction, type BatchDeleteReport, type BatchPipelineResult, type BulkResult, type ReferrerCounts } from '../utils/collection-ops'
|
|
3
|
+
import type { SerializedAction } from '@michaelthielemann/kestrel-core'
|
|
4
|
+
|
|
5
|
+
/** Which write pipeline each batch action runs, and the body it takes. A publish/unpublish is an
|
|
6
|
+
* `updateMany` with a status patch — the same pipeline any other field-wide edit would use. */
|
|
7
|
+
const BATCH_PIPELINE: Record<BatchAction, { op: string, patch?: { status: 'published' | 'draft' } }> = {
|
|
8
|
+
delete: { op: 'deleteMany' },
|
|
9
|
+
duplicate: { op: 'duplicate' },
|
|
10
|
+
publish: { op: 'updateMany', patch: { status: 'published' } },
|
|
11
|
+
unpublish: { op: 'updateMany', patch: { status: 'draft' } },
|
|
12
|
+
}
|
|
3
13
|
|
|
4
14
|
/**
|
|
5
15
|
* Client orchestration for the collection batch operations — ONE module, two entry points: a row action
|
|
6
|
-
* passes a single id, the bulk bar passes many. Every mutating call
|
|
7
|
-
* (`POST /api/{collection}/
|
|
16
|
+
* passes a single id, the bulk bar passes many. Every mutating call runs a batch pipeline
|
|
17
|
+
* (`POST /api/{collection}/{pipeline}`); the server does the batching, the client never loops N requests.
|
|
8
18
|
* Mirrors media/useMediaOps (busy/error + an onChanged refetch hook).
|
|
9
19
|
*/
|
|
10
20
|
export function useCollectionOps(collection: MaybeRefOrGetter<string>, onChanged?: () => void | Promise<void>) {
|
|
@@ -17,9 +27,14 @@ export function useCollectionOps(collection: MaybeRefOrGetter<string>, onChanged
|
|
|
17
27
|
busy.value = true
|
|
18
28
|
error.value = null
|
|
19
29
|
try {
|
|
20
|
-
const
|
|
30
|
+
const { op, patch } = BATCH_PIPELINE[action]
|
|
31
|
+
const res = await $fetch<BatchPipelineResult>(`/api/${name()}/${op}`, { method: 'POST', body: { ids, ...(patch ? { patch } : {}) } })
|
|
21
32
|
await onChanged?.()
|
|
22
|
-
|
|
33
|
+
// `duplicate` answers with the CREATED rows, the two batch ops with a count + the ids they touched;
|
|
34
|
+
// both fold into the one shape the list UI reports.
|
|
35
|
+
return Array.isArray(res)
|
|
36
|
+
? { action, count: res.length, ids: res.map((row) => Number(row.id)) }
|
|
37
|
+
: { action, count: res.count, ids: res.ids }
|
|
23
38
|
} catch (e) {
|
|
24
39
|
error.value = (e as { statusMessage?: string })?.statusMessage ?? t('list.opFailed')
|
|
25
40
|
throw e
|
|
@@ -33,7 +48,7 @@ export function useCollectionOps(collection: MaybeRefOrGetter<string>, onChanged
|
|
|
33
48
|
async function previewDelete(ids: number[]): Promise<BatchDeleteReport> {
|
|
34
49
|
error.value = null
|
|
35
50
|
try {
|
|
36
|
-
const res = await $fetch<ReferrerCounts>(
|
|
51
|
+
const res = await $fetch<ReferrerCounts>(`/api/${name()}/referrers`, { query: { ids: ids.join(',') } })
|
|
37
52
|
// The endpoint flags whether the index lookup ran; carry that through rather than inferring safety from
|
|
38
53
|
// an empty `counts`. A missing body is indeterminate too — it is no evidence that nothing links here.
|
|
39
54
|
return buildDeleteReport(ids, res?.counts ?? {}, res != null && res.checked !== false)
|
|
@@ -43,11 +58,33 @@ export function useCollectionOps(collection: MaybeRefOrGetter<string>, onChanged
|
|
|
43
58
|
}
|
|
44
59
|
}
|
|
45
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Run a schema-driven custom action (a consumer's `definePipeline` beyond the built-in bulk set). The
|
|
63
|
+
* response shape is whatever that pipeline answers with — unlike the built-ins, generic rendering has no
|
|
64
|
+
* contract to decode, so the reported result is simply the ids the caller asked to act on.
|
|
65
|
+
*/
|
|
66
|
+
async function runCustomAction(action: SerializedAction, ids: number[]): Promise<BulkResult> {
|
|
67
|
+
busy.value = true
|
|
68
|
+
error.value = null
|
|
69
|
+
try {
|
|
70
|
+
const record = action.kind === 'record'
|
|
71
|
+
const url = record ? `/api/${name()}/${action.name}/${ids[0]}` : `/api/${name()}/${action.name}`
|
|
72
|
+
await $fetch(url, { method: 'POST', body: record ? undefined : { ids } })
|
|
73
|
+
await onChanged?.()
|
|
74
|
+
return { action: action.name, count: ids.length, ids }
|
|
75
|
+
} catch (e) {
|
|
76
|
+
error.value = (e as { statusMessage?: string })?.statusMessage ?? t('list.opFailed')
|
|
77
|
+
throw e
|
|
78
|
+
} finally {
|
|
79
|
+
busy.value = false
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
46
83
|
const confirmDelete = (ids: number[]) => bulk('delete', ids)
|
|
47
84
|
const duplicate = (ids: number[]) => bulk('duplicate', ids)
|
|
48
85
|
/**
|
|
49
86
|
* Persist a new status and, for a publish, write the static output too (ADR-0008). Two calls because
|
|
50
|
-
* they are two things:
|
|
87
|
+
* they are two things: `updateMany` owns the DB write (validation, all-or-nothing, write events),
|
|
51
88
|
* `/api/publish` owns the render. Unpublishing needs no second call — the write event prunes the pages
|
|
52
89
|
* on its own, since taking a page offline must never wait for a separate action.
|
|
53
90
|
*/
|
|
@@ -61,5 +98,5 @@ export function useCollectionOps(collection: MaybeRefOrGetter<string>, onChanged
|
|
|
61
98
|
return res
|
|
62
99
|
}
|
|
63
100
|
|
|
64
|
-
return { busy, error, previewDelete, confirmDelete, duplicate, setStatus }
|
|
101
|
+
return { busy, error, previewDelete, confirmDelete, duplicate, setStatus, runCustomAction }
|
|
65
102
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import type { SerializedCollection } from '
|
|
1
|
+
import type { SerializedCollection } from '@michaelthielemann/kestrel-core'
|
|
2
2
|
|
|
3
|
-
// Thin wrapper over the shared cached-resource loader; keeps the named `collections` return.
|
|
4
3
|
export function useCollections() {
|
|
5
4
|
const { state: collections, load } = useCachedResource<SerializedCollection>('kestrel-collections', '/api/collections')
|
|
6
5
|
return { collections, load }
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { SerializedField } from '
|
|
2
|
-
import
|
|
1
|
+
import type { LayoutNode, SerializedField } from '@michaelthielemann/kestrel-core'
|
|
2
|
+
import { isFieldVisible } from '@michaelthielemann/kestrel-core/client'
|
|
3
|
+
import { validateField } from '@michaelthielemann/kestrel-fields/client'
|
|
3
4
|
import type { BlockErrorMap } from '../utils/edit-form'
|
|
4
5
|
import type { DeadRef } from '../utils/dead-refs'
|
|
5
|
-
import { asFieldDef, initialValues, mapServerErrors, parseBlockErrors, reconcileBlockErrors, readFetchError } from '../utils/edit-form'
|
|
6
|
+
import { asFieldDef, initialValues, mapServerErrors, parseBlockErrors, reconcileBlockErrors, reconcileDeadRefs, readFetchError } from '../utils/edit-form'
|
|
6
7
|
|
|
7
8
|
export interface UseEditFormOptions {
|
|
8
9
|
collection: string
|
|
@@ -51,6 +52,11 @@ export function useEditForm(opts: UseEditFormOptions) {
|
|
|
51
52
|
// but fall back defensively (blocks → 'blocks', else 'fields') so an older/partial schema still works.
|
|
52
53
|
const editorType = ref('fields')
|
|
53
54
|
|
|
55
|
+
// Set when the loaded row is the quarantine shape (`{ id, $quarantined: true }`) — a stored row
|
|
56
|
+
// that no longer matches its collection's select schema. The form locks read-only; nothing here can
|
|
57
|
+
// widen what a corrupted row exposes.
|
|
58
|
+
const quarantined = ref(false)
|
|
59
|
+
|
|
54
60
|
const values = reactive<Record<string, unknown>>({})
|
|
55
61
|
const errors = reactive<Record<string, string>>({})
|
|
56
62
|
const formError = ref('')
|
|
@@ -171,16 +177,18 @@ export function useEditForm(opts: UseEditFormOptions) {
|
|
|
171
177
|
|
|
172
178
|
let row: Record<string, unknown> | null = null
|
|
173
179
|
if (mode.value === 'single') {
|
|
174
|
-
row = await $fetch<Record<string, unknown> | null>(`/api/${collection}`, translatable.value ? { query: { locale: requestedLocale } } : {})
|
|
180
|
+
row = await $fetch<Record<string, unknown> | null>(`/api/${collection}/readOne`, translatable.value ? { query: { locale: requestedLocale } } : {})
|
|
175
181
|
activeLocale.value = requestedLocale
|
|
176
182
|
} else if (id !== 'new') {
|
|
177
|
-
row = await $fetch<Record<string, unknown>>(`/api/${collection}/${id}`)
|
|
178
|
-
|
|
183
|
+
row = await $fetch<Record<string, unknown>>(`/api/${collection}/readOne/${id}`)
|
|
184
|
+
// A quarantined row carries none of its normal fields — skip the locale/translations lookups, which
|
|
185
|
+
// assume a real row, and go straight to the read-only lock.
|
|
186
|
+
if (row?.$quarantined !== true && translatable.value) {
|
|
179
187
|
if (typeof row?.locale === 'string') activeLocale.value = row.locale
|
|
180
188
|
if (typeof row?.translationGroup === 'string') translationGroup.value = row.translationGroup
|
|
181
189
|
// Supplementary (drives the LocaleBar) — never block editing if it fails.
|
|
182
190
|
try {
|
|
183
|
-
translations.value = await $fetch<Record<string, number | null>>(`/api/${collection}/${id}
|
|
191
|
+
translations.value = await $fetch<Record<string, number | null>>(`/api/${collection}/translations/${id}`)
|
|
184
192
|
} catch {
|
|
185
193
|
translations.value = {}
|
|
186
194
|
}
|
|
@@ -199,18 +207,19 @@ export function useEditForm(opts: UseEditFormOptions) {
|
|
|
199
207
|
}
|
|
200
208
|
}
|
|
201
209
|
}
|
|
210
|
+
quarantined.value = row?.$quarantined === true
|
|
202
211
|
rebaseline(row)
|
|
203
212
|
|
|
204
213
|
// Supplementary dead-reference map (the editor warnings) — for any saved record, never blocks editing.
|
|
205
214
|
const recordId = typeof row?.id === 'number' ? row.id : null
|
|
206
|
-
if (recordId != null) {
|
|
215
|
+
if (recordId != null && !quarantined.value) {
|
|
207
216
|
try {
|
|
208
|
-
deadRefs.value = await $fetch<DeadRef[]>(`/api/${collection}/${recordId}
|
|
217
|
+
deadRefs.value = await $fetch<DeadRef[]>(`/api/${collection}/deadRefs/${recordId}`)
|
|
209
218
|
} catch {
|
|
210
219
|
deadRefs.value = []
|
|
211
220
|
}
|
|
212
221
|
} else {
|
|
213
|
-
deadRefs.value = [] // a new (unsaved) record has no references — keep it explicit
|
|
222
|
+
deadRefs.value = [] // a new (unsaved) or quarantined record has no references — keep it explicit
|
|
214
223
|
}
|
|
215
224
|
}
|
|
216
225
|
|
|
@@ -221,9 +230,14 @@ export function useEditForm(opts: UseEditFormOptions) {
|
|
|
221
230
|
function setField(name: string, value: unknown, coalesceAs: string = name) {
|
|
222
231
|
// Capture the pre-edit state for undo (coalesced for a same-field typing burst).
|
|
223
232
|
recordHistory(coalesceAs)
|
|
224
|
-
// Reconcile before overwriting: keep block errors through a reorder, drop them
|
|
233
|
+
// Reconcile before overwriting: keep block errors/dead-ref warnings through a reorder, drop them once
|
|
234
|
+
// the offending field itself is edited (swapping out a broken reference clears its own warning without
|
|
235
|
+
// a reload — the dead-ref map is otherwise only refetched on load).
|
|
225
236
|
if (name === 'content') {
|
|
226
237
|
blockErrors.value = reconcileBlockErrors(blockErrors.value, values.content as unknown[], value as unknown[])
|
|
238
|
+
deadRefs.value = reconcileDeadRefs(deadRefs.value, values.content as unknown[], value as unknown[])
|
|
239
|
+
} else if (deadRefs.value.some((r) => !r.blockId && r.field === name)) {
|
|
240
|
+
deadRefs.value = deadRefs.value.filter((r) => r.blockId || r.field !== name)
|
|
227
241
|
}
|
|
228
242
|
values[name] = value
|
|
229
243
|
formError.value = ''
|
|
@@ -279,9 +293,9 @@ export function useEditForm(opts: UseEditFormOptions) {
|
|
|
279
293
|
|
|
280
294
|
function handleError(e: unknown) {
|
|
281
295
|
const { statusCode, statusMessage, issues, data } = readFetchError(e)
|
|
282
|
-
// The row WAS written and a
|
|
283
|
-
//
|
|
284
|
-
//
|
|
296
|
+
// The row WAS written and a critical after-step failed (e.g. `writeRedirects`), so the server hands
|
|
297
|
+
// back the new `updatedAt`. Take it as the baseline: the client only rebaselines on success, and
|
|
298
|
+
// without this the "save again" those errors ask for would 409 on a stale precondition.
|
|
285
299
|
if (typeof data?.savedUpdatedAt === 'number') baseUpdatedAt.value = data.savedUpdatedAt
|
|
286
300
|
if (statusCode === 400) {
|
|
287
301
|
const mapped = mapServerErrors(issues)
|
|
@@ -305,6 +319,9 @@ export function useEditForm(opts: UseEditFormOptions) {
|
|
|
305
319
|
}
|
|
306
320
|
|
|
307
321
|
async function submit(): Promise<SubmitResult> {
|
|
322
|
+
// Sealed at the wire (validateOut) — a quarantined row's fields are unknown, so nothing here may
|
|
323
|
+
// send a write for it.
|
|
324
|
+
if (quarantined.value) return { ok: false }
|
|
308
325
|
formError.value = ''
|
|
309
326
|
blockErrors.value = {}
|
|
310
327
|
// Clear the whole map, not just declared-field errors: `path`/`seo`/`status` are system columns
|
|
@@ -326,10 +343,10 @@ export function useEditForm(opts: UseEditFormOptions) {
|
|
|
326
343
|
// `method` cast: Nuxt's typed `$fetch` over-constrains the method for this dynamic (template-literal)
|
|
327
344
|
// admin route to GET; the runtime value is correct, only the static route type can't express it.
|
|
328
345
|
const row = mode.value === 'single'
|
|
329
|
-
? await $fetch(`/api/${collection}`, { method: '
|
|
346
|
+
? await $fetch(`/api/${collection}/updateOne`, { method: 'POST' as never, body, headers: ifUnmodified, ...(translatable.value ? { query: { locale: activeLocale.value } } : {}) })
|
|
330
347
|
: id === 'new'
|
|
331
|
-
? await $fetch(`/api/${collection}`, { method: 'POST' as never, body })
|
|
332
|
-
: await $fetch(`/api/${collection}/${id}`, { method: '
|
|
348
|
+
? await $fetch(`/api/${collection}/createOne`, { method: 'POST' as never, body })
|
|
349
|
+
: await $fetch(`/api/${collection}/updateOne/${id}`, { method: 'POST' as never, body, headers: ifUnmodified })
|
|
333
350
|
rebaseline(row as Record<string, unknown>)
|
|
334
351
|
// `record` is the saved entity at the editor's public boundary; `row` stays the DB-layer term.
|
|
335
352
|
return { ok: true, record: row }
|
|
@@ -361,6 +378,7 @@ export function useEditForm(opts: UseEditFormOptions) {
|
|
|
361
378
|
pageLike,
|
|
362
379
|
hasSeo,
|
|
363
380
|
hasStatus,
|
|
381
|
+
quarantined,
|
|
364
382
|
values,
|
|
365
383
|
errors,
|
|
366
384
|
formError,
|
|
@@ -21,7 +21,7 @@ export function useLinkResolver() {
|
|
|
21
21
|
for (const k of keys) pending.add(k)
|
|
22
22
|
try {
|
|
23
23
|
const { data } = await $fetch<{ data: { collection: string; id: number; href: string }[] }>(
|
|
24
|
-
'/api/
|
|
24
|
+
'/api/resolveLinks', { query: { refs: keys.join(',') } },
|
|
25
25
|
)
|
|
26
26
|
const got = new Map(data.map((d) => [keyFor(d.collection, d.id), d.href]))
|
|
27
27
|
for (const k of keys) cache.set(k, got.get(k) ?? '') // cache misses too, as '' → no re-fetch
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Destructive → confirm dialog. Reversible → run, toast, refetch (the ops composable's onChanged).
|
|
5
5
|
import { ref, type ComputedRef } from 'vue'
|
|
6
|
+
import { resolveLocalized } from '../../../ui/app/utils/localized'
|
|
6
7
|
import type { BatchDeleteReport } from '../utils/collection-ops'
|
|
8
|
+
import type { SerializedAction } from '@michaelthielemann/kestrel-core'
|
|
7
9
|
|
|
8
10
|
export function useListBatchActions(collection: ComputedRef<string>, refetch: () => void | Promise<void>) {
|
|
9
|
-
const { t } = useT()
|
|
11
|
+
const { t, lang } = useT()
|
|
10
12
|
const toast = useToast()
|
|
11
|
-
const { busy, error, previewDelete, confirmDelete: runDelete, duplicate: runDuplicate, setStatus: runSetStatus } =
|
|
13
|
+
const { busy, error, previewDelete, confirmDelete: runDelete, duplicate: runDuplicate, setStatus: runSetStatus, runCustomAction } =
|
|
12
14
|
useCollectionOps(collection, () => refetch())
|
|
13
15
|
|
|
14
16
|
const deleteOpen = ref(false)
|
|
@@ -57,5 +59,20 @@ export function useListBatchActions(collection: ComputedRef<string>, refetch: ()
|
|
|
57
59
|
}
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
|
|
62
|
+
/** A schema-driven custom action: label falls back to the pipeline name, and an `ui.confirm` flag gates
|
|
63
|
+
* on a plain confirm prompt (the built-ins get the richer dialog above; a generic action has no report to
|
|
64
|
+
* preview). */
|
|
65
|
+
async function runAction(action: SerializedAction, ids: number[]) {
|
|
66
|
+
if (!ids.length) return
|
|
67
|
+
const label = resolveLocalized(action.label, lang.value) ?? action.name
|
|
68
|
+
if (action.confirm && !confirm(t('list.actionConfirm', { name: label, n: ids.length }))) return
|
|
69
|
+
try {
|
|
70
|
+
await runCustomAction(action, ids)
|
|
71
|
+
toast.success(t('list.actionDone', { name: label }))
|
|
72
|
+
} catch {
|
|
73
|
+
toast.error(error.value ?? t('list.opFailed'))
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { busy, error, deleteOpen, deleteReport, askDelete, confirmDelete, duplicate, setStatus, runAction }
|
|
61
78
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Ref } from 'vue'
|
|
2
|
-
import type { SerializedCollection } from '
|
|
2
|
+
import type { SerializedCollection } from '@michaelthielemann/kestrel-core'
|
|
3
3
|
import { availableColumns, defaultVisibleKeys, resolveVisibleColumns, type ListColumn } from '../utils/list-columns'
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
// `filter` (from the URL) is the committed state that drives the query; `draft` is what the panel shows
|
|
4
4
|
// while the user is still typing. A value change debounces, an operator change commits at once.
|
|
5
5
|
import { computed, onBeforeUnmount, reactive, watch, type ComputedRef } from 'vue'
|
|
6
|
-
import { DEFAULT_OP, OPS_BY_KIND
|
|
6
|
+
import { DEFAULT_OP, OPS_BY_KIND } from '@michaelthielemann/kestrel-core/client'
|
|
7
|
+
import type { FilterKind, FilterOp } from '@michaelthielemann/kestrel-core/client'
|
|
8
|
+
import type { Localized } from '@michaelthielemann/kestrel-core'
|
|
7
9
|
import { resolveLocalized } from '../../../ui/app/utils/localized'
|
|
8
10
|
import { columnLabel } from '../utils/list-cell'
|
|
9
|
-
import type { Localized } from '../../../core/server/utils/defineCollection'
|
|
10
11
|
import type { ListColumn } from '../utils/list-columns'
|
|
11
12
|
import type { FilterCell } from '../utils/list-query'
|
|
12
13
|
|