@michaelthielemann/kestrel 4.1.3 → 5.0.2
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 +26 -201
- package/dist/boot.d.ts +45 -0
- package/dist/boot.js +284 -0
- package/dist/cast.d.ts +3 -0
- package/dist/cast.js +6 -0
- package/dist/catalogue.d.ts +11 -0
- package/dist/catalogue.js +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +55 -0
- package/dist/context.d.ts +62 -0
- package/dist/context.js +61 -0
- package/dist/dataflow.d.ts +3 -0
- package/dist/dataflow.js +65 -0
- package/dist/defineConfig.d.ts +162 -0
- package/dist/defineConfig.js +42 -0
- package/dist/defineContract.d.ts +12 -0
- package/dist/defineContract.js +16 -0
- package/dist/defineModule.d.ts +75 -0
- package/dist/defineModule.js +9 -0
- package/dist/definePipeline.d.ts +7 -0
- package/dist/definePipeline.js +11 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.js +48 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +14 -0
- package/dist/load.d.ts +7 -0
- package/dist/load.js +79 -0
- package/dist/logger.d.ts +18 -0
- package/dist/logger.js +36 -0
- package/dist/registry.d.ts +14 -0
- package/dist/registry.js +87 -0
- package/dist/result.d.ts +19 -0
- package/dist/result.js +19 -0
- package/dist/runner.d.ts +29 -0
- package/dist/runner.js +133 -0
- package/dist/schema.d.ts +7 -0
- package/dist/schema.js +142 -0
- package/dist/sort.d.ts +2 -0
- package/dist/sort.js +50 -0
- package/dist/testing/runPipeline.d.ts +9 -0
- package/dist/testing/runPipeline.js +22 -0
- package/dist/triggers/allowlist.d.ts +9 -0
- package/dist/triggers/allowlist.js +49 -0
- package/dist/triggers/cron.d.ts +10 -0
- package/dist/triggers/cron.js +44 -0
- package/dist/triggers/cronMatch.d.ts +10 -0
- package/dist/triggers/cronMatch.js +68 -0
- package/dist/triggers/http.d.ts +59 -0
- package/dist/triggers/http.js +300 -0
- package/package.json +25 -151
- package/NOTICE +0 -12
- package/kestrel.config.ts +0 -28
- package/layers/access/nuxt.config.ts +0 -5
- package/layers/access/server/middleware/00.ip-allowlist.ts +0 -52
- package/layers/access/server/middleware/access-guard.ts +0 -32
- package/layers/access/server/types.d.ts +0 -7
- package/layers/admin/app/components/AdminAccount.vue +0 -212
- package/layers/admin/app/components/AdminNav.vue +0 -44
- package/layers/admin/app/components/BlockFields.vue +0 -67
- package/layers/admin/app/components/BlockPreview.vue +0 -413
- package/layers/admin/app/components/BlockTree.vue +0 -433
- package/layers/admin/app/components/BlocksBody.vue +0 -163
- package/layers/admin/app/components/CollectionDeleteDialog.vue +0 -38
- package/layers/admin/app/components/CollectionEditor.vue +0 -324
- package/layers/admin/app/components/CollectionList.vue +0 -429
- package/layers/admin/app/components/CollectionListBulkBar.vue +0 -62
- package/layers/admin/app/components/CollectionListColumnsPanel.vue +0 -37
- package/layers/admin/app/components/CollectionListFilterPanel.vue +0 -145
- package/layers/admin/app/components/CollectionListPager.vue +0 -70
- package/layers/admin/app/components/CollectionListTable.vue +0 -347
- package/layers/admin/app/components/EditorStatus.vue +0 -211
- package/layers/admin/app/components/EditorUnsupported.vue +0 -28
- package/layers/admin/app/components/FieldsBody.vue +0 -23
- package/layers/admin/app/components/LocaleBar.vue +0 -107
- package/layers/admin/app/components/PageFields.vue +0 -129
- package/layers/admin/app/components/PageFieldsPane.vue +0 -39
- package/layers/admin/app/components/SeoFields.vue +0 -210
- package/layers/admin/app/components/SingletonEditor.vue +0 -93
- package/layers/admin/app/composables/useAuth.ts +0 -68
- package/layers/admin/app/composables/useBlockTree.ts +0 -95
- package/layers/admin/app/composables/useBlocks.ts +0 -7
- package/layers/admin/app/composables/useCachedResource.ts +0 -35
- package/layers/admin/app/composables/useCollectionOps.ts +0 -102
- package/layers/admin/app/composables/useCollections.ts +0 -6
- package/layers/admin/app/composables/useContentLocales.ts +0 -23
- package/layers/admin/app/composables/useEditForm.ts +0 -402
- package/layers/admin/app/composables/useLinkResolver.ts +0 -40
- package/layers/admin/app/composables/useListBatchActions.ts +0 -78
- package/layers/admin/app/composables/useListColumns.ts +0 -53
- package/layers/admin/app/composables/useListFilterDraft.ts +0 -129
- package/layers/admin/app/composables/useListRows.ts +0 -75
- package/layers/admin/app/composables/useListSelection.ts +0 -28
- package/layers/admin/app/composables/useListUrlState.ts +0 -53
- package/layers/admin/app/composables/usePendingPublishPoll.ts +0 -15
- package/layers/admin/app/composables/usePreviewViewport.ts +0 -30
- package/layers/admin/app/composables/usePublishStatus.ts +0 -107
- package/layers/admin/app/composables/useRailCollapsed.ts +0 -14
- package/layers/admin/app/composables/useTheme.ts +0 -36
- package/layers/admin/app/composables/useToolbarPanel.ts +0 -38
- package/layers/admin/app/composables/useUnsavedGuard.ts +0 -22
- package/layers/admin/app/layouts/admin.vue +0 -266
- package/layers/admin/app/middleware/admin-auth.ts +0 -4
- package/layers/admin/app/pages/admin/[collection]/[id].vue +0 -203
- package/layers/admin/app/pages/admin/[collection]/index.vue +0 -62
- package/layers/admin/app/pages/admin/index.vue +0 -118
- package/layers/admin/app/pages/admin/login.vue +0 -68
- package/layers/admin/app/pages/admin/media.vue +0 -28
- package/layers/admin/app/pages/admin/references.vue +0 -146
- package/layers/admin/app/plugins/admin-style-guard.client.ts +0 -76
- package/layers/admin/app/plugins/reauth.client.ts +0 -20
- package/layers/admin/app/utils/admin-style-guard.ts +0 -20
- package/layers/admin/app/utils/block-tree.ts +0 -214
- package/layers/admin/app/utils/collection-ops.ts +0 -69
- package/layers/admin/app/utils/dead-refs.ts +0 -30
- package/layers/admin/app/utils/edit-form.ts +0 -267
- package/layers/admin/app/utils/editor-expose.ts +0 -36
- package/layers/admin/app/utils/editor-form-context.ts +0 -82
- package/layers/admin/app/utils/editor-registry.ts +0 -20
- package/layers/admin/app/utils/field-keys.ts +0 -11
- package/layers/admin/app/utils/list-cell.ts +0 -46
- package/layers/admin/app/utils/list-columns.ts +0 -142
- package/layers/admin/app/utils/list-query.ts +0 -96
- package/layers/admin/app/utils/nav-active.ts +0 -9
- package/layers/admin/app/utils/populate-blocks.ts +0 -288
- package/layers/admin/app/utils/preview-channel.ts +0 -62
- package/layers/admin/app/utils/preview-viewport.ts +0 -80
- package/layers/admin/app/utils/reauth.ts +0 -67
- package/layers/admin/app/utils/record-title.ts +0 -15
- package/layers/admin/app/utils/register-builtin-editors.ts +0 -9
- package/layers/admin/app/utils/safe-redirect.ts +0 -12
- package/layers/admin/nuxt.config.ts +0 -20
- package/layers/auth/nuxt.config.ts +0 -1
- package/layers/auth/server/plugins/01.register-auth-pipelines.ts +0 -7
- package/layers/collections/nuxt.config.ts +0 -1
- package/layers/collections/server/plugins/01.register.ts +0 -29
- package/layers/collections/server/plugins/02.register-relation-populate.ts +0 -18
- package/layers/core/app/composables/layouts.ts +0 -5
- package/layers/core/app/utils/field-factories.ts +0 -129
- package/layers/core/app/utils/layouts.ts +0 -38
- package/layers/core/modules/auto-discovery/extract-block.ts +0 -179
- package/layers/core/modules/auto-discovery/index.ts +0 -112
- package/layers/core/modules/auto-discovery/package-registry.ts +0 -19
- package/layers/core/modules/auto-discovery/scan.ts +0 -170
- package/layers/core/modules/auto-discovery/virtual.d.ts +0 -20
- package/layers/core/modules/component-namespace/index.ts +0 -44
- package/layers/core/modules/component-namespace/shared.ts +0 -24
- package/layers/core/modules/kestrel/app-shell.ts +0 -55
- package/layers/core/modules/kestrel/auto-imports.ts +0 -44
- package/layers/core/modules/kestrel/index.ts +0 -144
- package/layers/core/modules/plugin-order/index.ts +0 -69
- package/layers/core/modules/plugin-order/plugin-order.ts +0 -273
- package/layers/core/nuxt.config.ts +0 -10
- package/layers/core/server/api/[...path].ts +0 -98
- package/layers/core/server/plugins/00.config.ts +0 -12
- package/layers/core/server/plugins/00.migrate.ts +0 -12
- package/layers/core/server/plugins/01.register-introspection-pipeline.ts +0 -5
- package/layers/core/server/plugins/01.register-openapi-pipeline.ts +0 -5
- package/layers/core/server/plugins/01.register-tooling-pipelines.ts +0 -5
- package/layers/core/server/plugins/02.schema-sync.ts +0 -58
- package/layers/core/server/plugins/04.outbox-worker.ts +0 -31
- package/layers/core/server/plugins/05.reindex-refs.ts +0 -5
- package/layers/core/server/routes/__kestrel/dashboard.get.ts +0 -29
- package/layers/core/server/tasks/db/migrate-module.ts +0 -83
- package/layers/core/server/tasks/db/migrate-revisions.ts +0 -20
- package/layers/core/server/tasks/db/migrate.ts +0 -50
- package/layers/core/server/utils/server-config.ts +0 -81
- package/layers/fields/nuxt.config.ts +0 -1
- package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +0 -7
- package/layers/fields/server/plugins/01.register-field-populate.ts +0 -11
- package/layers/media/app/components/KestrelImg.vue +0 -94
- package/layers/media/app/components/MediaConflictDialog.vue +0 -35
- package/layers/media/app/components/MediaDeleteDialog.vue +0 -52
- package/layers/media/app/components/MediaGrid.vue +0 -113
- package/layers/media/app/components/MediaImage.vue +0 -70
- package/layers/media/app/components/MediaLibrary.vue +0 -456
- package/layers/media/app/components/MediaNewFolderDialog.vue +0 -32
- package/layers/media/app/components/MediaPathBar.vue +0 -163
- package/layers/media/app/components/MediaPicker.vue +0 -19
- package/layers/media/app/components/MediaRenameDialog.vue +0 -32
- package/layers/media/app/components/MediaTable.vue +0 -75
- package/layers/media/app/components/MediaThumb.vue +0 -25
- package/layers/media/app/components/MediaToolbar.vue +0 -90
- package/layers/media/app/components/MediaUploadDialog.vue +0 -87
- package/layers/media/app/components/MediaViewer.vue +0 -137
- package/layers/media/app/components/field/Media.vue +0 -263
- package/layers/media/app/composables/useBlurUp.ts +0 -55
- package/layers/media/app/composables/useMediaClipboard.ts +0 -14
- package/layers/media/app/composables/useMediaContextMenu.ts +0 -56
- package/layers/media/app/composables/useMediaDnd.ts +0 -56
- package/layers/media/app/composables/useMediaLibrary.ts +0 -152
- package/layers/media/app/composables/useMediaOps.ts +0 -50
- package/layers/media/app/composables/useMediaRelocate.ts +0 -39
- package/layers/media/app/composables/useMediaResolver.ts +0 -50
- package/layers/media/app/composables/useMediaUpload.ts +0 -93
- package/layers/media/app/composables/useMediaVariant.ts +0 -34
- package/layers/media/app/plugins/field-media.client.ts +0 -6
- package/layers/media/app/utils/ai-disclosure.ts +0 -19
- package/layers/media/app/utils/dnd.ts +0 -69
- package/layers/media/app/utils/library.ts +0 -81
- package/layers/media/app/utils/ops.ts +0 -60
- package/layers/media/app/utils/picture-model.ts +0 -125
- package/layers/media/app/utils/thumbhash.ts +0 -19
- package/layers/media/app/utils/variant-name.ts +0 -20
- package/layers/media/nuxt.config.ts +0 -7
- package/layers/media/server/middleware/ondemand-variants.ts +0 -38
- package/layers/media/server/plugins/01.register-media-pipelines.ts +0 -7
- package/layers/media/server/plugins/02.register-media.ts +0 -22
- package/layers/media/server/plugins/04.variant-capture.ts +0 -27
- package/layers/media/server/plugins/05.media-cleanup.ts +0 -5
- package/layers/media/server/tasks/media/backfill.ts +0 -17
- package/layers/public/app/app.vue +0 -5
- package/layers/public/app/components/BlockRenderer.vue +0 -56
- package/layers/public/app/components/KestrelLink.vue +0 -26
- package/layers/public/app/components/KestrelPreviewBridge.vue +0 -133
- package/layers/public/app/composables/public-page.ts +0 -24
- package/layers/public/app/error.vue +0 -12
- package/layers/public/app/layouts/default.vue +0 -5
- package/layers/public/app/pages/[...slug].vue +0 -239
- package/layers/public/app/pages/__kestrel/preview.vue +0 -46
- package/layers/public/app/utils/block-edit-context.ts +0 -16
- package/layers/public/app/utils/json-ld.ts +0 -139
- package/layers/public/app/utils/link-href.ts +0 -46
- package/layers/public/app/utils/page-head.ts +0 -84
- package/layers/public/app/utils/page-layout.ts +0 -18
- package/layers/public/app/utils/preview-protocol.ts +0 -117
- package/layers/public/app/utils/public-route.ts +0 -46
- package/layers/public/app/utils/site-head.ts +0 -43
- package/layers/public/modules/deploy-output/index.ts +0 -80
- package/layers/public/modules/prerender-routes/discover.ts +0 -37
- package/layers/public/modules/prerender-routes/index.ts +0 -88
- package/layers/public/modules/prerender-routes/routes.ts +0 -16
- package/layers/public/modules/prune-media/index.ts +0 -48
- package/layers/public/modules/prune-media/prune-media.ts +0 -81
- package/layers/public/nuxt.config.ts +0 -14
- package/layers/public/server/delivery-live/serve.ts +0 -111
- package/layers/public/server/middleware/delivery-live-catchall.ts +0 -7
- package/layers/public/server/pipelines/index.ts +0 -23
- package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +0 -14
- package/layers/public/server/plugins/01.register-public-pipelines.ts +0 -7
- package/layers/public/server/plugins/02.register-links.ts +0 -16
- package/layers/public/server/plugins/03.redirects.ts +0 -55
- package/layers/public/server/plugins/05.plan-publish.ts +0 -5
- package/layers/public/server/plugins/zz.publish.ts +0 -127
- package/layers/public/server/routes/llms-full.txt.get.ts +0 -99
- package/layers/public/server/routes/llms.txt.get.ts +0 -66
- package/layers/public/server/routes/redirects.json.get.ts +0 -58
- package/layers/public/server/routes/robots.txt.get.ts +0 -11
- package/layers/public/server/routes/sitemap.xml.get.ts +0 -79
- package/layers/public/server/tasks/publish/run.ts +0 -32
- package/layers/public/server/utils/publish/render-live.ts +0 -26
- package/layers/ui/app/assets/scss/_base.scss +0 -16
- package/layers/ui/app/assets/scss/_datepicker.scss +0 -82
- package/layers/ui/app/assets/scss/_mixins.scss +0 -50
- package/layers/ui/app/assets/scss/_reset.scss +0 -65
- package/layers/ui/app/assets/scss/_tokens.scss +0 -183
- package/layers/ui/app/assets/scss/main.scss +0 -4
- package/layers/ui/app/components/field/Boolean.vue +0 -30
- package/layers/ui/app/components/field/Choice.vue +0 -73
- package/layers/ui/app/components/field/Datetime.vue +0 -98
- package/layers/ui/app/components/field/DeadRefNote.vue +0 -28
- package/layers/ui/app/components/field/Json.vue +0 -62
- package/layers/ui/app/components/field/Layout.vue +0 -138
- package/layers/ui/app/components/field/Link.vue +0 -112
- package/layers/ui/app/components/field/LinkInternalPicker.vue +0 -95
- package/layers/ui/app/components/field/Number.vue +0 -30
- package/layers/ui/app/components/field/Relation.vue +0 -49
- package/layers/ui/app/components/field/Renderer.vue +0 -30
- package/layers/ui/app/components/field/Repeater.vue +0 -460
- package/layers/ui/app/components/field/Richtext.vue +0 -26
- package/layers/ui/app/components/field/Slug.vue +0 -65
- package/layers/ui/app/components/field/Text.vue +0 -35
- package/layers/ui/app/components/field/Unsupported.vue +0 -34
- package/layers/ui/app/components/ui/Alert.vue +0 -50
- package/layers/ui/app/components/ui/Brand.vue +0 -22
- package/layers/ui/app/components/ui/Button.vue +0 -116
- package/layers/ui/app/components/ui/ButtonGroup.vue +0 -96
- package/layers/ui/app/components/ui/Checkbox.vue +0 -53
- package/layers/ui/app/components/ui/CheckboxGroup.vue +0 -51
- package/layers/ui/app/components/ui/Combobox.vue +0 -345
- package/layers/ui/app/components/ui/DatePicker.vue +0 -92
- package/layers/ui/app/components/ui/DateRangePicker.vue +0 -100
- package/layers/ui/app/components/ui/Dialog.vue +0 -107
- package/layers/ui/app/components/ui/EmptyState.vue +0 -46
- package/layers/ui/app/components/ui/Field.vue +0 -58
- package/layers/ui/app/components/ui/Fieldset.vue +0 -65
- package/layers/ui/app/components/ui/Icon.vue +0 -44
- package/layers/ui/app/components/ui/Menu.vue +0 -54
- package/layers/ui/app/components/ui/NumberInput.vue +0 -175
- package/layers/ui/app/components/ui/Richtext.vue +0 -141
- package/layers/ui/app/components/ui/RichtextToolbar.vue +0 -251
- package/layers/ui/app/components/ui/Select.vue +0 -52
- package/layers/ui/app/components/ui/TextInput.vue +0 -111
- package/layers/ui/app/components/ui/Textarea.vue +0 -39
- package/layers/ui/app/components/ui/TimeInput.vue +0 -18
- package/layers/ui/app/components/ui/Toasts.vue +0 -103
- package/layers/ui/app/components/ui/Tooltip.vue +0 -45
- package/layers/ui/app/composables/useAdminLang.ts +0 -8
- package/layers/ui/app/composables/useDragReorder.ts +0 -41
- package/layers/ui/app/composables/useEchoGuard.ts +0 -28
- package/layers/ui/app/composables/useLinkField.ts +0 -70
- package/layers/ui/app/composables/useRecordOptions.ts +0 -84
- package/layers/ui/app/composables/useRepeater.ts +0 -99
- package/layers/ui/app/composables/useT.ts +0 -32
- package/layers/ui/app/composables/useToast.ts +0 -46
- package/layers/ui/app/i18n/de.ts +0 -432
- package/layers/ui/app/i18n/en.ts +0 -434
- package/layers/ui/app/utils/boolean-field.ts +0 -10
- package/layers/ui/app/utils/field-component.ts +0 -22
- package/layers/ui/app/utils/field-empty.ts +0 -21
- package/layers/ui/app/utils/field-registry.ts +0 -36
- package/layers/ui/app/utils/humanize.ts +0 -14
- package/layers/ui/app/utils/icons.ts +0 -143
- package/layers/ui/app/utils/iso-date.ts +0 -18
- package/layers/ui/app/utils/localized.ts +0 -13
- package/layers/ui/app/utils/record-context.ts +0 -15
- package/layers/ui/app/utils/reorder.ts +0 -8
- package/layers/ui/app/utils/richtext-preserve-class.ts +0 -31
- package/layers/ui/app/utils/useFieldA11y.ts +0 -40
- package/layers/ui/nuxt.config.ts +0 -9
- package/nuxt.config.ts +0 -108
- package/scripts/consumer-template-ci.mjs +0 -160
- package/scripts/copy-create-payload.mjs +0 -49
- package/scripts/coverage-all.mjs +0 -163
- package/scripts/dashboard.mjs +0 -89
- package/scripts/hash-password.mjs +0 -29
- package/scripts/kestrel.mjs +0 -240
- package/scripts/lib/cli.mjs +0 -129
- package/scripts/lib/coverage-model.mjs +0 -51
- package/scripts/lib/password.mjs +0 -19
- package/scripts/lib/scaffold.mjs +0 -205
- package/scripts/port-graph.mjs +0 -350
- package/scripts/typecheck.mjs +0 -62
- package/templates/starter/README.md +0 -65
- package/templates/starter/_env.example +0 -17
- package/templates/starter/_gitignore +0 -26
- package/templates/starter/_package.json +0 -22
- package/templates/starter/app/app.vue +0 -7
- package/templates/starter/app/blocks/Prose.vue +0 -13
- package/templates/starter/app/layouts/default.vue +0 -6
- package/templates/starter/nuxt.config.ts +0 -23
- package/templates/starter/pnpm-workspace.yaml +0 -8
- package/templates/starter/tsconfig.json +0 -3
|
@@ -1,402 +0,0 @@
|
|
|
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'
|
|
4
|
-
import type { BlockErrorMap } from '../utils/edit-form'
|
|
5
|
-
import type { DeadRef } from '../utils/dead-refs'
|
|
6
|
-
import { asFieldDef, initialValues, mapServerErrors, parseBlockErrors, reconcileBlockErrors, reconcileDeadRefs, readFetchError } from '../utils/edit-form'
|
|
7
|
-
|
|
8
|
-
export interface UseEditFormOptions {
|
|
9
|
-
collection: string
|
|
10
|
-
id: string
|
|
11
|
-
/** Requested content locale (from the `?locale` route query). Defaults to the primary locale. */
|
|
12
|
-
locale?: string
|
|
13
|
-
/** translationGroup to link a NEW multi translation to its siblings (from the `?group` query). */
|
|
14
|
-
group?: string
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export type SubmitResult = { ok: true; record: unknown } | { ok: false }
|
|
18
|
-
|
|
19
|
-
function snapshot(values: Record<string, unknown>): Record<string, unknown> {
|
|
20
|
-
return JSON.parse(JSON.stringify(values))
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function useEditForm(opts: UseEditFormOptions) {
|
|
24
|
-
const { collection, id } = opts
|
|
25
|
-
const { t } = useT()
|
|
26
|
-
const { primary } = useContentLocales()
|
|
27
|
-
// The locale the editor *requests* (single GET/PUT + a new multi translation). For an existing multi
|
|
28
|
-
// row the active locale comes from the row itself (set in init); it can't be switched via a query.
|
|
29
|
-
const requestedLocale = opts.locale?.trim() || primary
|
|
30
|
-
const activeLocale = ref(requestedLocale)
|
|
31
|
-
const translations = ref<Record<string, number | null>>({})
|
|
32
|
-
// Stale references this record currently holds (derived on read) — drives the editor's dead-ref warnings.
|
|
33
|
-
const deadRefs = ref<DeadRef[]>([])
|
|
34
|
-
// The translation group the LocaleBar links new siblings to: the requested group for a new
|
|
35
|
-
// translation, or the loaded row's own group for an existing multi record.
|
|
36
|
-
const translationGroup = ref<string | undefined>(opts.group?.trim() || undefined)
|
|
37
|
-
|
|
38
|
-
const fields = ref<Record<string, SerializedField>>({})
|
|
39
|
-
// The collection's normalized admin editor layout (resolved server-side), or undefined → one field per row.
|
|
40
|
-
const fieldLayout = ref<LayoutNode[] | undefined>(undefined)
|
|
41
|
-
const mode = ref<'multi' | 'single'>('multi')
|
|
42
|
-
const translatable = ref(false)
|
|
43
|
-
const blocksEnabled = ref(false)
|
|
44
|
-
const blocksAllowed = ref<string[] | undefined>(undefined)
|
|
45
|
-
// pageLike collections carry a routable `path` (the slug) as a system column, edited in the page editor.
|
|
46
|
-
const pageLike = ref(false)
|
|
47
|
-
// seo-enabled collections carry a `seo` JSON system column ({ title, description, noindex }).
|
|
48
|
-
const hasSeo = ref(false)
|
|
49
|
-
// status-enabled collections carry a `status` system column ('draft' | 'published') — the publish gate.
|
|
50
|
-
const hasStatus = ref(false)
|
|
51
|
-
// Which editor body renders (the `editor` type on the serialized collection). The server resolves it,
|
|
52
|
-
// but fall back defensively (blocks → 'blocks', else 'fields') so an older/partial schema still works.
|
|
53
|
-
const editorType = ref('fields')
|
|
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
|
-
|
|
60
|
-
const values = reactive<Record<string, unknown>>({})
|
|
61
|
-
const errors = reactive<Record<string, string>>({})
|
|
62
|
-
const formError = ref('')
|
|
63
|
-
const blockErrors = ref<BlockErrorMap>({})
|
|
64
|
-
const saving = ref(false)
|
|
65
|
-
const baseline = ref<Record<string, unknown>>({})
|
|
66
|
-
const baseUpdatedAt = ref<number | undefined>(undefined)
|
|
67
|
-
|
|
68
|
-
const dirty = computed(() => !valuesEqual(values, baseline.value))
|
|
69
|
-
// The LAST SAVED publish status (from the baseline snapshot, not the live edited value). The editor's
|
|
70
|
-
// right "live" lamp keys its draft/published decision off this — an UNSAVED dropdown switch must not flip
|
|
71
|
-
// the generated-state indicator (nothing is generated until the change is saved). '' when statusless.
|
|
72
|
-
const savedStatus = computed(() => (hasStatus.value ? ((baseline.value.status as string | undefined) ?? '') : ''))
|
|
73
|
-
// The LAST SAVED page path (baseline, not the live edited slug). The preview iframe + the header's
|
|
74
|
-
// open-in-new-tab must target a URL that actually EXISTS — an unsaved slug edit would 404 until saved;
|
|
75
|
-
// on save the baseline moves and both follow to the new URL. '' when non-pageLike or never saved.
|
|
76
|
-
const savedPath = computed(() => (pageLike.value ? ((baseline.value.path as string | undefined) ?? '') : ''))
|
|
77
|
-
|
|
78
|
-
// Undo/redo: snapshot history of the whole form state. Every edit funnels through setField (block-tree
|
|
79
|
-
// ops route through setField('content', …)), so that's the single capture point. A rapid burst of edits
|
|
80
|
-
// to the same field coalesces into one step; the stacks reset on load/save (no undo across a save).
|
|
81
|
-
const past = ref<Record<string, unknown>[]>([])
|
|
82
|
-
const future = ref<Record<string, unknown>[]>([])
|
|
83
|
-
const canUndo = computed(() => past.value.length > 0)
|
|
84
|
-
const canRedo = computed(() => future.value.length > 0)
|
|
85
|
-
let coalesceKey = ''
|
|
86
|
-
let coalesceAt = 0
|
|
87
|
-
const COALESCE_MS = 600
|
|
88
|
-
const HISTORY_LIMIT = 100
|
|
89
|
-
|
|
90
|
-
function recordHistory(name: string) {
|
|
91
|
-
const now = Date.now()
|
|
92
|
-
const coalesce = name === coalesceKey && now - coalesceAt < COALESCE_MS
|
|
93
|
-
coalesceKey = name
|
|
94
|
-
coalesceAt = now
|
|
95
|
-
if (coalesce) return
|
|
96
|
-
past.value.push(snapshot(values))
|
|
97
|
-
if (past.value.length > HISTORY_LIMIT) past.value.shift()
|
|
98
|
-
future.value = []
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function restore(snap: Record<string, unknown>) {
|
|
102
|
-
const fresh = snapshot(snap)
|
|
103
|
-
for (const k of Object.keys(fresh)) values[k] = fresh[k]
|
|
104
|
-
for (const k of Object.keys(values)) if (!(k in fresh)) Reflect.deleteProperty(values, k)
|
|
105
|
-
for (const k of Object.keys(errors)) Reflect.deleteProperty(errors, k)
|
|
106
|
-
blockErrors.value = {}
|
|
107
|
-
formError.value = ''
|
|
108
|
-
coalesceKey = ''
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function undo() {
|
|
112
|
-
if (!past.value.length) return
|
|
113
|
-
future.value.push(snapshot(values))
|
|
114
|
-
restore(past.value.pop()!)
|
|
115
|
-
}
|
|
116
|
-
function redo() {
|
|
117
|
-
if (!future.value.length) return
|
|
118
|
-
past.value.push(snapshot(values))
|
|
119
|
-
restore(future.value.pop()!)
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const renderableFields = computed<Record<string, SerializedField>>(() => {
|
|
123
|
-
if (!blocksEnabled.value) return fields.value
|
|
124
|
-
const { content: _content, ...rest } = fields.value
|
|
125
|
-
return rest
|
|
126
|
-
})
|
|
127
|
-
|
|
128
|
-
function fieldKeys() {
|
|
129
|
-
return Object.keys(fields.value)
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function rebaseline(source: Record<string, unknown> | null) {
|
|
133
|
-
const next = initialValues(fields.value)
|
|
134
|
-
if (source) {
|
|
135
|
-
for (const k of fieldKeys()) {
|
|
136
|
-
const col = jsKey(k, fields.value[k]!)
|
|
137
|
-
if (col in source) next[k] = source[col]
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
// `content` is a synthesized blocks column, not a def.field — round-trip it explicitly.
|
|
141
|
-
if (blocksEnabled.value) next.content = (source?.content as unknown[]) ?? []
|
|
142
|
-
// `path` (the page slug) is a pageLike system column, likewise round-tripped explicitly.
|
|
143
|
-
if (pageLike.value) next.path = (source?.path as string | null | undefined) ?? ''
|
|
144
|
-
// '' is the "no override" form the select binds to.
|
|
145
|
-
if (pageLike.value) next.layout = (source?.layout as string | null | undefined) ?? ''
|
|
146
|
-
// `seo` is a JSON system column; default to an empty object so the editor can fill it in.
|
|
147
|
-
if (hasSeo.value) next.seo = (source?.seo as Record<string, unknown> | undefined) ?? {}
|
|
148
|
-
// `status` is a system column; a new record defaults to 'draft' (unpublished) — matches the DB default.
|
|
149
|
-
if (hasStatus.value) next.status = (source?.status as string | undefined) ?? 'draft'
|
|
150
|
-
for (const k of Object.keys(next)) values[k] = next[k]
|
|
151
|
-
baseline.value = snapshot(values)
|
|
152
|
-
// The optimistic-concurrency baseline: the `updatedAt` this load/save established, sent back on the
|
|
153
|
-
// next save so a stale tab (loaded before someone else saved) is refused with 409 instead of
|
|
154
|
-
// silently reverting their change. undefined for a never-saved new record.
|
|
155
|
-
const stamp = source?.updatedAt
|
|
156
|
-
baseUpdatedAt.value = stamp != null ? new Date(stamp as string | number).getTime() : undefined
|
|
157
|
-
// A fresh load or a save establishes a new baseline — history does not span it.
|
|
158
|
-
past.value = []
|
|
159
|
-
future.value = []
|
|
160
|
-
coalesceKey = ''
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
async function init() {
|
|
164
|
-
const all = await useCollections().load()
|
|
165
|
-
const schema = all.find((c) => c.name === collection)
|
|
166
|
-
if (!schema) throw createError({ statusCode: 404, statusMessage: `Unknown collection: ${collection}` })
|
|
167
|
-
fields.value = schema.fields
|
|
168
|
-
fieldLayout.value = schema.fieldLayout
|
|
169
|
-
mode.value = schema.mode
|
|
170
|
-
translatable.value = schema.translatable
|
|
171
|
-
blocksEnabled.value = schema.blocks?.enabled ?? false
|
|
172
|
-
blocksAllowed.value = schema.blocks?.allowed
|
|
173
|
-
pageLike.value = schema.pageLike ?? false
|
|
174
|
-
hasSeo.value = schema.seo ?? false
|
|
175
|
-
hasStatus.value = schema.status ?? false
|
|
176
|
-
editorType.value = (schema as { editor?: string }).editor ?? (schema.blocks?.enabled ? 'blocks' : 'fields')
|
|
177
|
-
|
|
178
|
-
let row: Record<string, unknown> | null = null
|
|
179
|
-
if (mode.value === 'single') {
|
|
180
|
-
row = await $fetch<Record<string, unknown> | null>(`/api/${collection}/readOne`, translatable.value ? { query: { locale: requestedLocale } } : {})
|
|
181
|
-
activeLocale.value = requestedLocale
|
|
182
|
-
} else if (id !== 'new') {
|
|
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) {
|
|
187
|
-
if (typeof row?.locale === 'string') activeLocale.value = row.locale
|
|
188
|
-
if (typeof row?.translationGroup === 'string') translationGroup.value = row.translationGroup
|
|
189
|
-
// Supplementary (drives the LocaleBar) — never block editing if it fails.
|
|
190
|
-
try {
|
|
191
|
-
translations.value = await $fetch<Record<string, number | null>>(`/api/${collection}/translations/${id}`)
|
|
192
|
-
} catch {
|
|
193
|
-
translations.value = {}
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
} else {
|
|
197
|
-
activeLocale.value = requestedLocale
|
|
198
|
-
// A new sibling translation has no id yet, so its locale→id map is resolved by GROUP. Without it the
|
|
199
|
-
// LocaleBar shows every already-translated locale as a "+" create link: the copy-from source is
|
|
200
|
-
// unreachable, and following that link saves a second row for an occupied locale — a duplicate-locale
|
|
201
|
-
// 409 the user can't resolve. Supplementary (drives the LocaleBar) — never block editing if it fails.
|
|
202
|
-
if (translatable.value && translationGroup.value) {
|
|
203
|
-
try {
|
|
204
|
-
translations.value = await $fetch<Record<string, number | null>>(`/api/${collection}/translations`, { query: { group: translationGroup.value } })
|
|
205
|
-
} catch {
|
|
206
|
-
translations.value = {}
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
quarantined.value = row?.$quarantined === true
|
|
211
|
-
rebaseline(row)
|
|
212
|
-
|
|
213
|
-
// Supplementary dead-reference map (the editor warnings) — for any saved record, never blocks editing.
|
|
214
|
-
const recordId = typeof row?.id === 'number' ? row.id : null
|
|
215
|
-
if (recordId != null && !quarantined.value) {
|
|
216
|
-
try {
|
|
217
|
-
deadRefs.value = await $fetch<DeadRef[]>(`/api/${collection}/deadRefs/${recordId}`)
|
|
218
|
-
} catch {
|
|
219
|
-
deadRefs.value = []
|
|
220
|
-
}
|
|
221
|
-
} else {
|
|
222
|
-
deadRefs.value = [] // a new (unsaved) or quarantined record has no references — keep it explicit
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// `coalesceAs` lets a caller opt out of the default same-name coalescing: block-tree structural ops
|
|
227
|
-
// (remove/move/duplicate/add) all round-trip through setField('content', …) like a text edit does, so
|
|
228
|
-
// without a distinct key a delete landing inside another edit's 600ms burst would merge into it and
|
|
229
|
-
// become impossible to undo on its own.
|
|
230
|
-
function setField(name: string, value: unknown, coalesceAs: string = name) {
|
|
231
|
-
// Capture the pre-edit state for undo (coalesced for a same-field typing burst).
|
|
232
|
-
recordHistory(coalesceAs)
|
|
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).
|
|
236
|
-
if (name === 'content') {
|
|
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)
|
|
241
|
-
}
|
|
242
|
-
values[name] = value
|
|
243
|
-
formError.value = ''
|
|
244
|
-
const field = fields.value[name]
|
|
245
|
-
if (field) errors[name] = isFieldVisible(field, values) ? (validateField(asFieldDef(field), value) ?? '') : ''
|
|
246
|
-
// A controller edit can hide sibling fields — drop their now-irrelevant advisory errors so a
|
|
247
|
-
// hidden required field never shows a phantom error or blocks the save.
|
|
248
|
-
for (const [n, f] of Object.entries(fields.value)) if (!isFieldVisible(f, values)) errors[n] = ''
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/** Copy another locale's field/content values into this form (the LocaleBar "copy" action). Goes
|
|
252
|
-
* through setField so validation, block-error reconcile, and dirty-tracking all apply. */
|
|
253
|
-
function applyFrom(source: Record<string, unknown>) {
|
|
254
|
-
for (const k of fieldKeys()) {
|
|
255
|
-
const col = jsKey(k, fields.value[k]!)
|
|
256
|
-
if (col in source) setField(k, source[col])
|
|
257
|
-
}
|
|
258
|
-
if (blocksEnabled.value && 'content' in source) setField('content', source.content)
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function validateAll(): boolean {
|
|
262
|
-
let ok = true
|
|
263
|
-
for (const [name, field] of Object.entries(fields.value)) {
|
|
264
|
-
// A hidden field is exempt from validation (its `required` only applies when shown) — mirrors
|
|
265
|
-
// the server's required-when-visible enforcement, so the client never blocks a valid save.
|
|
266
|
-
if (!isFieldVisible(field, values)) { errors[name] = ''; continue }
|
|
267
|
-
const msg = validateField(asFieldDef(field), values[name])
|
|
268
|
-
errors[name] = msg ?? ''
|
|
269
|
-
if (msg) ok = false
|
|
270
|
-
}
|
|
271
|
-
return ok
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function buildBody(): Record<string, unknown> {
|
|
275
|
-
const body: Record<string, unknown> = {}
|
|
276
|
-
for (const k of fieldKeys()) body[jsKey(k, fields.value[k]!)] = values[k]
|
|
277
|
-
if (blocksEnabled.value) body.content = values.content
|
|
278
|
-
// Send the slug as the routable path; a blank slug clears the route (stored as null, not "").
|
|
279
|
-
if (pageLike.value) body.path = (values.path as string) ? values.path : null
|
|
280
|
-
// An unset layout is stored as NULL, never '': the render coalesces NULL to `default`, and a stored ''
|
|
281
|
-
// would be indistinguishable from a name that failed to save.
|
|
282
|
-
if (pageLike.value) body.layout = (values.layout as string) || null
|
|
283
|
-
if (hasSeo.value) body.seo = values.seo ?? {}
|
|
284
|
-
if (hasStatus.value) body.status = (values.status as string) ?? 'draft'
|
|
285
|
-
// A new translatable multi record carries its locale, and links to a translation group when it is
|
|
286
|
-
// a sibling translation. Existing rows keep their locale; the singleton uses the ?locale query.
|
|
287
|
-
if (translatable.value && mode.value === 'multi' && id === 'new') {
|
|
288
|
-
body.locale = activeLocale.value
|
|
289
|
-
if (translationGroup.value) body.translationGroup = translationGroup.value
|
|
290
|
-
}
|
|
291
|
-
return body
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function handleError(e: unknown) {
|
|
295
|
-
const { statusCode, statusMessage, issues, data } = readFetchError(e)
|
|
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.
|
|
299
|
-
if (typeof data?.savedUpdatedAt === 'number') baseUpdatedAt.value = data.savedUpdatedAt
|
|
300
|
-
if (statusCode === 400) {
|
|
301
|
-
const mapped = mapServerErrors(issues)
|
|
302
|
-
for (const [k, msg] of Object.entries(mapped.fields)) errors[k] = msg
|
|
303
|
-
// `content` errors are nested (content[i].props.x) and have no flat field to render them,
|
|
304
|
-
// so surface a form-level banner instead of failing silently.
|
|
305
|
-
blockErrors.value = parseBlockErrors(issues, values.content as unknown[])
|
|
306
|
-
// A field error (e.g. the page slug) renders inline in PageFields, but that pane is unmounted while a
|
|
307
|
-
// block is selected — so also raise a banner whenever there are field errors, never a silent failure.
|
|
308
|
-
formError.value = mapped.form
|
|
309
|
-
?? (mapped.fields.content
|
|
310
|
-
? t('editor.fixBlockContent')
|
|
311
|
-
: Object.keys(mapped.fields).length ? t('editor.fixPageFields') : '')
|
|
312
|
-
} else if (statusCode === 409) {
|
|
313
|
-
// `||`, not `??`: an absent reason phrase arrives as '' rather than undefined, and an empty banner
|
|
314
|
-
// is a silent failure — the Save button simply stops spinning.
|
|
315
|
-
formError.value = statusMessage || t('editor.saveConflict')
|
|
316
|
-
} else {
|
|
317
|
-
formError.value = statusMessage || t('editor.saveFailed')
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
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 }
|
|
325
|
-
formError.value = ''
|
|
326
|
-
blockErrors.value = {}
|
|
327
|
-
// Clear the whole map, not just declared-field errors: `path`/`seo`/`status` are system columns
|
|
328
|
-
// (never in `fields.value`), so a stale server error on one of them would otherwise survive a
|
|
329
|
-
// later successful save. `validateAll` below repopulates every declared-field error fresh.
|
|
330
|
-
for (const k of Object.keys(errors)) Reflect.deleteProperty(errors, k)
|
|
331
|
-
// Client-side validation failed: surface the same banner the server path raises, so the failure is
|
|
332
|
-
// never silent — the offending field's inline error can sit in an unmounted pane (blocks editor).
|
|
333
|
-
if (!validateAll()) {
|
|
334
|
-
formError.value = t('editor.fixPageFields')
|
|
335
|
-
return { ok: false }
|
|
336
|
-
}
|
|
337
|
-
saving.value = true
|
|
338
|
-
try {
|
|
339
|
-
const body = buildBody()
|
|
340
|
-
// The optimistic-concurrency precondition: send the baseline `updatedAt` so a stale overwrite 409s
|
|
341
|
-
// instead of silently reverting a concurrent save. Omitted for a brand-new record (no baseline).
|
|
342
|
-
const ifUnmodified: Record<string, string> = baseUpdatedAt.value !== undefined ? { 'x-kestrel-if-unmodified-since': String(baseUpdatedAt.value) } : {}
|
|
343
|
-
// `method` cast: Nuxt's typed `$fetch` over-constrains the method for this dynamic (template-literal)
|
|
344
|
-
// admin route to GET; the runtime value is correct, only the static route type can't express it.
|
|
345
|
-
const row = mode.value === 'single'
|
|
346
|
-
? await $fetch(`/api/${collection}/updateOne`, { method: 'POST' as never, body, headers: ifUnmodified, ...(translatable.value ? { query: { locale: activeLocale.value } } : {}) })
|
|
347
|
-
: id === 'new'
|
|
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 })
|
|
350
|
-
rebaseline(row as Record<string, unknown>)
|
|
351
|
-
// `record` is the saved entity at the editor's public boundary; `row` stays the DB-layer term.
|
|
352
|
-
return { ok: true, record: row }
|
|
353
|
-
} catch (e) {
|
|
354
|
-
handleError(e)
|
|
355
|
-
return { ok: false }
|
|
356
|
-
} finally {
|
|
357
|
-
saving.value = false
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
const ready = init()
|
|
362
|
-
|
|
363
|
-
return {
|
|
364
|
-
collection,
|
|
365
|
-
locale: activeLocale,
|
|
366
|
-
translations,
|
|
367
|
-
deadRefs,
|
|
368
|
-
translationGroup,
|
|
369
|
-
applyFrom,
|
|
370
|
-
mode,
|
|
371
|
-
fields,
|
|
372
|
-
fieldLayout,
|
|
373
|
-
renderableFields,
|
|
374
|
-
translatable,
|
|
375
|
-
blocksEnabled,
|
|
376
|
-
blocksAllowed,
|
|
377
|
-
editorType,
|
|
378
|
-
pageLike,
|
|
379
|
-
hasSeo,
|
|
380
|
-
hasStatus,
|
|
381
|
-
quarantined,
|
|
382
|
-
values,
|
|
383
|
-
errors,
|
|
384
|
-
formError,
|
|
385
|
-
blockErrors,
|
|
386
|
-
dirty,
|
|
387
|
-
savedStatus,
|
|
388
|
-
savedPath,
|
|
389
|
-
saving,
|
|
390
|
-
ready,
|
|
391
|
-
setField,
|
|
392
|
-
validateAll,
|
|
393
|
-
// The wire body a save would send — also what a preview ticket carries, so an external tab renders
|
|
394
|
-
// exactly what a save would have stored (ADR-0008).
|
|
395
|
-
buildBody,
|
|
396
|
-
submit,
|
|
397
|
-
undo,
|
|
398
|
-
redo,
|
|
399
|
-
canUndo,
|
|
400
|
-
canRedo,
|
|
401
|
-
}
|
|
402
|
-
}
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
import { reactive } from 'vue'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Batched, cached client resolution of internal-link refs (`{collection, id}`) → public href,
|
|
5
|
-
* mirroring the server `resolveInternalHref` so the live preview's `KestrelLink` renders real internal
|
|
6
|
-
* paths instead of `'#'`. Per instance (no shared module state → SSR-safe). No locale param: an
|
|
7
|
-
* internal link points to one specific record, whose href is its own locale's path. Misses are cached
|
|
8
|
-
* (empty string) so a dangling (missing-target) ref isn't re-fetched on every preview recompute.
|
|
9
|
-
*/
|
|
10
|
-
export function useLinkResolver() {
|
|
11
|
-
const cache = reactive(new Map<string, string>())
|
|
12
|
-
const pending = new Set<string>()
|
|
13
|
-
const keyFor = (collection: string, id: number) => `${collection}:${id}`
|
|
14
|
-
|
|
15
|
-
async function ensure(refs: { collection: string; id: number }[]): Promise<void> {
|
|
16
|
-
const missing = refs.filter((r) =>
|
|
17
|
-
!!r.collection && Number.isInteger(r.id) && r.id > 0
|
|
18
|
-
&& !cache.has(keyFor(r.collection, r.id)) && !pending.has(keyFor(r.collection, r.id)))
|
|
19
|
-
if (!missing.length) return
|
|
20
|
-
const keys = missing.map((r) => keyFor(r.collection, r.id))
|
|
21
|
-
for (const k of keys) pending.add(k)
|
|
22
|
-
try {
|
|
23
|
-
const { data } = await $fetch<{ data: { collection: string; id: number; href: string }[] }>(
|
|
24
|
-
'/api/resolveLinks', { query: { refs: keys.join(',') } },
|
|
25
|
-
)
|
|
26
|
-
const got = new Map(data.map((d) => [keyFor(d.collection, d.id), d.href]))
|
|
27
|
-
for (const k of keys) cache.set(k, got.get(k) ?? '') // cache misses too, as '' → no re-fetch
|
|
28
|
-
} catch {
|
|
29
|
-
// leave unresolved — the preview shows '#' for these until a later attempt
|
|
30
|
-
} finally {
|
|
31
|
-
for (const k of keys) pending.delete(k)
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function resolve(collection: string, id: number): string | null {
|
|
36
|
-
return cache.get(keyFor(collection, id)) || null
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
return { cache, ensure, resolve }
|
|
40
|
-
}
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
// Row and bulk actions for the collection list: a row action is one id, the bulk bar is N — the server
|
|
2
|
-
// does the batching, so both go through the same calls.
|
|
3
|
-
//
|
|
4
|
-
// Destructive → confirm dialog. Reversible → run, toast, refetch (the ops composable's onChanged).
|
|
5
|
-
import { ref, type ComputedRef } from 'vue'
|
|
6
|
-
import { resolveLocalized } from '../../../ui/app/utils/localized'
|
|
7
|
-
import type { BatchDeleteReport } from '../utils/collection-ops'
|
|
8
|
-
import type { SerializedAction } from '@michaelthielemann/kestrel-core'
|
|
9
|
-
|
|
10
|
-
export function useListBatchActions(collection: ComputedRef<string>, refetch: () => void | Promise<void>) {
|
|
11
|
-
const { t, lang } = useT()
|
|
12
|
-
const toast = useToast()
|
|
13
|
-
const { busy, error, previewDelete, confirmDelete: runDelete, duplicate: runDuplicate, setStatus: runSetStatus, runCustomAction } =
|
|
14
|
-
useCollectionOps(collection, () => refetch())
|
|
15
|
-
|
|
16
|
-
const deleteOpen = ref(false)
|
|
17
|
-
const deleteReport = ref<BatchDeleteReport | null>(null)
|
|
18
|
-
const deleteIds = ref<number[]>([])
|
|
19
|
-
|
|
20
|
-
// The referrer preview is best-effort: if it fails we still open the dialog with a bare summary (never
|
|
21
|
-
// block a delete on the warning lookup).
|
|
22
|
-
async function askDelete(ids: number[]) {
|
|
23
|
-
if (!ids.length) return
|
|
24
|
-
deleteIds.value = ids
|
|
25
|
-
deleteReport.value = null
|
|
26
|
-
deleteOpen.value = true
|
|
27
|
-
try {
|
|
28
|
-
deleteReport.value = await previewDelete(ids)
|
|
29
|
-
} catch {
|
|
30
|
-
// The referrer lookup FAILED — do not fabricate a "no inbound links" report (that would look exactly
|
|
31
|
-
// like a verified-safe delete). Flag `checked: false` so the dialog cautions the check couldn't run.
|
|
32
|
-
deleteReport.value = { count: ids.length, referencedCount: 0, referenced: [], checked: false }
|
|
33
|
-
error.value = null
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
async function confirmDelete() {
|
|
37
|
-
try {
|
|
38
|
-
await runDelete(deleteIds.value)
|
|
39
|
-
toast.success(t('toast.deleted'))
|
|
40
|
-
deleteOpen.value = false
|
|
41
|
-
} catch {
|
|
42
|
-
// error stays surfaced in the dialog via `error`
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
async function duplicate(id: number) {
|
|
46
|
-
try {
|
|
47
|
-
await runDuplicate([id])
|
|
48
|
-
toast.success(t('toast.duplicated'))
|
|
49
|
-
} catch {
|
|
50
|
-
toast.error(error.value ?? t('list.opFailed'))
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
async function setStatus(ids: number[], status: 'published' | 'draft') {
|
|
54
|
-
try {
|
|
55
|
-
await runSetStatus(ids, status)
|
|
56
|
-
toast.success(status === 'published' ? t('toast.published') : t('toast.unpublished'))
|
|
57
|
-
} catch {
|
|
58
|
-
toast.error(error.value ?? t('list.opFailed'))
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
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 }
|
|
78
|
-
}
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import type { Ref } from 'vue'
|
|
2
|
-
import type { SerializedCollection } from '@michaelthielemann/kestrel-core'
|
|
3
|
-
import { availableColumns, defaultVisibleKeys, resolveVisibleColumns, type ListColumn } from '../utils/list-columns'
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Per-collection list column visibility, persisted in a cookie (SSR-safe, like the rail-collapsed state).
|
|
7
|
-
* The store maps collection name → the chosen visible keys; an absent entry falls back to the default set.
|
|
8
|
-
* Stale keys (a since-removed field) are dropped on read via `resolveVisibleColumns`.
|
|
9
|
-
*/
|
|
10
|
-
export function useListColumns(schema: Ref<SerializedCollection>) {
|
|
11
|
-
const store = useCookie<Record<string, string[]>>('kestrel-list-columns', { default: () => ({}) })
|
|
12
|
-
|
|
13
|
-
const available = computed<ListColumn[]>(() => availableColumns(schema.value))
|
|
14
|
-
const defaults = computed<string[]>(() => defaultVisibleKeys(schema.value))
|
|
15
|
-
|
|
16
|
-
const visibleKeys = computed<string[]>(() => {
|
|
17
|
-
const stored = store.value[schema.value.name]
|
|
18
|
-
// Project through the available columns so order is canonical and removed columns disappear.
|
|
19
|
-
return resolveVisibleColumns(available.value, stored ?? defaults.value).map((c) => c.key)
|
|
20
|
-
})
|
|
21
|
-
const visibleColumns = computed<ListColumn[]>(() => resolveVisibleColumns(available.value, visibleKeys.value))
|
|
22
|
-
|
|
23
|
-
function persist(keys: string[]) {
|
|
24
|
-
// Keep canonical order so persisted prefs round-trip stably.
|
|
25
|
-
const ordered = available.value.filter((c) => keys.includes(c.key)).map((c) => c.key)
|
|
26
|
-
store.value = { ...store.value, [schema.value.name]: ordered }
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function toggle(key: string) {
|
|
30
|
-
const next = new Set(visibleKeys.value)
|
|
31
|
-
if (next.has(key)) {
|
|
32
|
-
if (next.size <= 1) return // never hide the last column
|
|
33
|
-
next.delete(key)
|
|
34
|
-
} else {
|
|
35
|
-
next.add(key)
|
|
36
|
-
}
|
|
37
|
-
persist([...next])
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function reset() {
|
|
41
|
-
const next = { ...store.value }
|
|
42
|
-
Reflect.deleteProperty(next, schema.value.name)
|
|
43
|
-
store.value = next
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const isDefault = computed(() => {
|
|
47
|
-
const a = visibleKeys.value
|
|
48
|
-
const b = defaults.value
|
|
49
|
-
return a.length === b.length && a.every((k, i) => k === b[i])
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
return { available, visibleColumns, visibleKeys, toggle, reset, isDefault }
|
|
53
|
-
}
|