@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,129 +0,0 @@
|
|
|
1
|
-
// The Filter panel's pending edits, and how they become committed clauses in the URL.
|
|
2
|
-
//
|
|
3
|
-
// `filter` (from the URL) is the committed state that drives the query; `draft` is what the panel shows
|
|
4
|
-
// while the user is still typing. A value change debounces, an operator change commits at once.
|
|
5
|
-
import { computed, onBeforeUnmount, reactive, watch, type ComputedRef } from 'vue'
|
|
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'
|
|
9
|
-
import { resolveLocalized } from '../../../ui/app/utils/localized'
|
|
10
|
-
import { columnLabel } from '../utils/list-cell'
|
|
11
|
-
import type { ListColumn } from '../utils/list-columns'
|
|
12
|
-
import type { FilterCell } from '../utils/list-query'
|
|
13
|
-
|
|
14
|
-
const COMMIT_DEBOUNCE_MS = 250
|
|
15
|
-
|
|
16
|
-
/** One active clause as the chip row renders it. */
|
|
17
|
-
export interface ActiveFilter {
|
|
18
|
-
key: string
|
|
19
|
-
op: FilterOp
|
|
20
|
-
opLabel: string
|
|
21
|
-
label: string
|
|
22
|
-
display: string
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** The panel's whole controller — passed to the filter panel as one prop rather than a dozen. */
|
|
26
|
-
export interface ListFilterDraft {
|
|
27
|
-
draft: Record<string, FilterCell>
|
|
28
|
-
activeFilters: ComputedRef<ActiveFilter[]>
|
|
29
|
-
opsFor: (c: ListColumn) => readonly FilterOp[]
|
|
30
|
-
opLabel: (kind: FilterKind, op: FilterOp) => string
|
|
31
|
-
enumOptions: (c: ListColumn) => { label: string; value: string }[]
|
|
32
|
-
setValue: (field: string, value: string) => void
|
|
33
|
-
setOp: (field: string, op: FilterOp) => void
|
|
34
|
-
clearFilter: (field: string) => void
|
|
35
|
-
clearAll: () => void
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
interface FilterDraftOptions {
|
|
39
|
-
available: ComputedRef<ListColumn[]>
|
|
40
|
-
filterableColumns: ComputedRef<ListColumn[]>
|
|
41
|
-
filter: ComputedRef<Record<string, FilterCell>>
|
|
42
|
-
setFilter: (next: Record<string, FilterCell>) => void
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function useListFilterDraft(opts: FilterDraftOptions): ListFilterDraft {
|
|
46
|
-
const { t, lang } = useT()
|
|
47
|
-
const draft = reactive<Record<string, FilterCell>>({})
|
|
48
|
-
|
|
49
|
-
// Overwrite each draft cell FROM the committed `filter` so deep-link / Back-Forward / chip-removal reflect
|
|
50
|
-
// back into the open panel; drop drafts for columns this collection no longer has. We key the watch on the
|
|
51
|
-
// committed filter's CONTENT (its JSON), not its object identity: `filter` recomputes to a fresh-but-EQUAL
|
|
52
|
-
// object on every `route.query` change (page/sort/perPage navigations too), and resyncing on those identity
|
|
53
|
-
// flips would wipe an in-progress panel edit. Clobber-safe on a real filter commit: a commit sends ALL
|
|
54
|
-
// drafts at once, so post-commit `filter` equals `draft` (a no-op overwrite) — the content only actually
|
|
55
|
-
// changes on external navigation (deep-link/Back-Forward/chip-removal), which is when the panel should resync.
|
|
56
|
-
watch([opts.filterableColumns, () => JSON.stringify(opts.filter.value)], () => {
|
|
57
|
-
const cols = opts.filterableColumns.value
|
|
58
|
-
for (const c of cols) {
|
|
59
|
-
const cm = opts.filter.value[c.key]
|
|
60
|
-
draft[c.key] = { op: cm?.op ?? DEFAULT_OP[c.filterKind ?? 'text'], value: cm?.value ?? '' }
|
|
61
|
-
}
|
|
62
|
-
for (const key of Object.keys(draft)) if (!cols.some((c) => c.key === key)) Reflect.deleteProperty(draft, key)
|
|
63
|
-
}, { immediate: true })
|
|
64
|
-
|
|
65
|
-
function opsFor(c: ListColumn): readonly FilterOp[] {
|
|
66
|
-
return OPS_BY_KIND[c.filterKind ?? 'text']
|
|
67
|
-
}
|
|
68
|
-
// Operator labels are kind-aware: a datetime comparison reads "before / on or before / …" instead of the
|
|
69
|
-
// generic "less than / less or equal / …".
|
|
70
|
-
function opLabel(kind: FilterKind, op: FilterOp): string {
|
|
71
|
-
if (kind === 'datetime' && (op === 'lt' || op === 'lte' || op === 'gt' || op === 'gte')) return t(`filter.opDate.${op}`)
|
|
72
|
-
return t(`filter.op.${op}`)
|
|
73
|
-
}
|
|
74
|
-
// Choices for an `enum` / `stringSet` value control: a fixed draft/published set for `status`, else the
|
|
75
|
-
// choice field's own (single- and multi-choice fields both carry `options.choices`).
|
|
76
|
-
function enumOptions(c: ListColumn): { label: string; value: string }[] {
|
|
77
|
-
if (c.key === 'status') return [
|
|
78
|
-
{ value: 'draft', label: t('pageSettings.statusDraft') },
|
|
79
|
-
{ value: 'published', label: t('pageSettings.statusPublished') },
|
|
80
|
-
]
|
|
81
|
-
// A choice label is `Localized`, so resolve it here as the editor widget does — `{{ o.label }}` would
|
|
82
|
-
// stringify a `{ en, de }` map into the filter dropdown.
|
|
83
|
-
return ((c.field?.options?.choices ?? []) as { label: Localized; value: string }[])
|
|
84
|
-
.map((o) => ({ value: o.value, label: resolveLocalized(o.label, lang.value) ?? o.value }))
|
|
85
|
-
}
|
|
86
|
-
function displayValue(kind: FilterKind, value: string): string {
|
|
87
|
-
if (kind === 'boolean') return value === 'true' ? t('filter.bool.true') : value === 'false' ? t('filter.bool.false') : value
|
|
88
|
-
return value
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Builds a fresh clause map from the non-empty draft cells and navigates via `setFilter` (which also
|
|
92
|
-
// resets page → 1).
|
|
93
|
-
let timer: ReturnType<typeof setTimeout> | undefined
|
|
94
|
-
function commitDrafts() {
|
|
95
|
-
const next: Record<string, FilterCell> = {}
|
|
96
|
-
for (const [field, cell] of Object.entries(draft)) {
|
|
97
|
-
if (cell.value !== '' && cell.value != null) next[field] = { op: cell.op, value: cell.value }
|
|
98
|
-
}
|
|
99
|
-
opts.setFilter(next)
|
|
100
|
-
}
|
|
101
|
-
function setValue(field: string, value: string) {
|
|
102
|
-
if (draft[field]) draft[field].value = value
|
|
103
|
-
clearTimeout(timer)
|
|
104
|
-
timer = setTimeout(commitDrafts, COMMIT_DEBOUNCE_MS)
|
|
105
|
-
}
|
|
106
|
-
function setOp(field: string, op: FilterOp) {
|
|
107
|
-
if (draft[field]) draft[field].op = op
|
|
108
|
-
// An operator change is deliberate — re-query immediately (no debounce) if the field has a value.
|
|
109
|
-
clearTimeout(timer)
|
|
110
|
-
commitDrafts()
|
|
111
|
-
}
|
|
112
|
-
function clearFilter(field: string) {
|
|
113
|
-
if (draft[field]) draft[field].value = ''
|
|
114
|
-
commitDrafts() // re-commits the drafts with this one now empty → the clause is dropped (and page → 1)
|
|
115
|
-
}
|
|
116
|
-
function clearAll() {
|
|
117
|
-
for (const cell of Object.values(draft)) cell.value = ''
|
|
118
|
-
opts.setFilter({}) // clear the committed clauses (and page → 1); the draft-resync watch clears the panel
|
|
119
|
-
}
|
|
120
|
-
onBeforeUnmount(() => clearTimeout(timer))
|
|
121
|
-
|
|
122
|
-
const activeFilters = computed(() => Object.entries(opts.filter.value).map(([key, cell]) => {
|
|
123
|
-
const col = opts.available.value.find((c) => c.key === key)
|
|
124
|
-
const kind = col?.filterKind ?? 'text'
|
|
125
|
-
return { key, op: cell.op, opLabel: opLabel(kind, cell.op), label: col ? columnLabel(col, t) : key, display: displayValue(kind, cell.value) }
|
|
126
|
-
}))
|
|
127
|
-
|
|
128
|
-
return { draft, activeFilters, opsFor, opLabel, enumOptions, setValue, setOp, clearFilter, clearAll }
|
|
129
|
-
}
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
// The collection list's row fetch: request, out-of-range clamp, and the inline error state.
|
|
2
|
-
import { computed, ref, type ComputedRef, type Ref } from 'vue'
|
|
3
|
-
|
|
4
|
-
interface ListRowsOptions {
|
|
5
|
-
collection: ComputedRef<string>
|
|
6
|
-
/** The sanitized query derived from the URL (sort / page / perPage / filter). */
|
|
7
|
-
effectiveQuery: ComputedRef<Record<string, unknown>>
|
|
8
|
-
locale: () => string | undefined
|
|
9
|
-
page: ComputedRef<number>
|
|
10
|
-
perPage: ComputedRef<number>
|
|
11
|
-
/** Navigate back into range when the current page emptied out; REPLACE, never push. */
|
|
12
|
-
clampPage: (page: number) => void
|
|
13
|
-
/** Runs after a SUCCESSFUL fetch — the caller drops its page-scoped state here. A failed fetch keeps
|
|
14
|
-
* the previously rendered rows, so anything scoped to them stays valid. */
|
|
15
|
-
onLoaded?: () => void
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
interface ListResponse {
|
|
19
|
-
data: Record<string, unknown>[]
|
|
20
|
-
total: number
|
|
21
|
-
page: number
|
|
22
|
-
perPage: number
|
|
23
|
-
/** Set by `validateOut` — count of rows in `data` replaced with the quarantine shape. */
|
|
24
|
-
quarantinedCount?: number
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function useListRows(opts: ListRowsOptions) {
|
|
28
|
-
const { t } = useT()
|
|
29
|
-
const rows = ref<Record<string, unknown>[]>([])
|
|
30
|
-
const total = ref(0)
|
|
31
|
-
const quarantinedCount = ref(0)
|
|
32
|
-
// Surfaced when a (re)fetch fails — an inline error + Retry, never a silent stale list or a thrown
|
|
33
|
-
// error boundary. Distinct from the empty state (which only shows when the fetch SUCCEEDED with no rows).
|
|
34
|
-
const error = ref<string | null>(null)
|
|
35
|
-
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / opts.perPage.value)))
|
|
36
|
-
|
|
37
|
-
// Monotonic request id: a slow earlier response must not overwrite a newer one.
|
|
38
|
-
let seq = 0
|
|
39
|
-
async function fetchRows() {
|
|
40
|
-
const mine = ++seq
|
|
41
|
-
const locale = opts.locale()
|
|
42
|
-
try {
|
|
43
|
-
const res = await $fetch<ListResponse>(`/api/${opts.collection.value}/readMany`, {
|
|
44
|
-
query: { ...opts.effectiveQuery.value, ...(locale ? { locale } : {}) },
|
|
45
|
-
})
|
|
46
|
-
if (mine !== seq) return
|
|
47
|
-
rows.value = res.data
|
|
48
|
-
total.value = res.total
|
|
49
|
-
quarantinedCount.value = res.quarantinedCount ?? 0
|
|
50
|
-
error.value = null
|
|
51
|
-
// A page that emptied out from under us — e.g. bulk-deleting the last rows of the trailing page —
|
|
52
|
-
// would otherwise strand the user on an out-of-range page showing the "create your first" empty
|
|
53
|
-
// state. Clamp back to the last page that still has rows (REPLACE, so the dead page isn't left as a
|
|
54
|
-
// history entry that Back would bounce off; the query watch refetches).
|
|
55
|
-
if (res.data.length === 0 && res.total > 0 && opts.page.value > 1) {
|
|
56
|
-
opts.clampPage(Math.max(1, Math.ceil(res.total / opts.perPage.value)))
|
|
57
|
-
}
|
|
58
|
-
opts.onLoaded?.()
|
|
59
|
-
} catch (e) {
|
|
60
|
-
if (mine !== seq) return
|
|
61
|
-
// Keep any previously-rendered rows, but surface that this fetch failed (and don't show the
|
|
62
|
-
// "create your first" empty state — that would imply the collection is empty when it isn't).
|
|
63
|
-
error.value = (e as { statusMessage?: string })?.statusMessage ?? t('list.loadError')
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
return { rows, total, quarantinedCount, error, totalPages, fetchRows } as {
|
|
68
|
-
rows: Ref<Record<string, unknown>[]>
|
|
69
|
-
total: Ref<number>
|
|
70
|
-
quarantinedCount: Ref<number>
|
|
71
|
-
error: Ref<string | null>
|
|
72
|
-
totalPages: ComputedRef<number>
|
|
73
|
-
fetchRows: () => Promise<void>
|
|
74
|
-
}
|
|
75
|
-
}
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
// Page-scoped row selection for the collection list.
|
|
2
|
-
//
|
|
3
|
-
// The Set is valid only for the rows currently on screen — every (re)fetch replaces them, so the caller
|
|
4
|
-
// clears it from its fetch handler. A reactive Set (Vue tracks add/delete/has/size) keeps a single-row
|
|
5
|
-
// toggle O(1): no full-Set copy and no whole-table re-diff per click, which mattered at the 500-row cap.
|
|
6
|
-
import { computed, reactive, type Ref } from 'vue'
|
|
7
|
-
|
|
8
|
-
export function useListSelection(rows: Ref<Record<string, unknown>[]>) {
|
|
9
|
-
const selected = reactive(new Set<number>())
|
|
10
|
-
const pageIds = computed(() => rows.value.map((r) => Number(r.id)))
|
|
11
|
-
const allSelected = computed(() => pageIds.value.length > 0 && pageIds.value.every((id) => selected.has(id)))
|
|
12
|
-
// Tri-state header checkbox: some rows selected, but not all of them.
|
|
13
|
-
const headerIndeterminate = computed(() => pageIds.value.some((id) => selected.has(id)) && !allSelected.value)
|
|
14
|
-
|
|
15
|
-
function toggleRow(id: number, on: boolean) {
|
|
16
|
-
if (on) selected.add(id)
|
|
17
|
-
else selected.delete(id)
|
|
18
|
-
}
|
|
19
|
-
function toggleAll(on: boolean) {
|
|
20
|
-
selected.clear()
|
|
21
|
-
if (on) for (const id of pageIds.value) selected.add(id)
|
|
22
|
-
}
|
|
23
|
-
function clear() {
|
|
24
|
-
selected.clear()
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
return { selected, allSelected, headerIndeterminate, toggleRow, toggleAll, clear }
|
|
28
|
-
}
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
import type { Ref } from 'vue'
|
|
2
|
-
import type { ListColumn } from '../utils/list-columns'
|
|
3
|
-
import { toQuery, toggleSort, parseListQuery, type FilterCell, type ListState } from '../utils/list-query'
|
|
4
|
-
import { clampPerPage, DEFAULT_PER_PAGE } from '@michaelthielemann/kestrel-core/client'
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* The URL is the SINGLE SOURCE OF TRUTH for a collection list's committed state (sort · page · perPage ·
|
|
8
|
-
* filter). This composable derives that state from `route.query` (sanitized via `parseListQuery`, so a
|
|
9
|
-
* hostile/partial URL degrades to defaults rather than reaching the server), and turns each mutation into a
|
|
10
|
-
* router navigation: `page` uses `push` (Back walks pages), everything else uses `replace` (typing / toggling
|
|
11
|
-
* must not spam history). Every non-page action also resets `page → 1`, so a page-only transition is the only
|
|
12
|
-
* one that ever differs by page alone — the push/replace split is exact.
|
|
13
|
-
*
|
|
14
|
-
* The per-page cookie is a DEFAULT, never state: when the URL carries no `perPage`, the initial value is
|
|
15
|
-
* seeded from the cookie (clamped); when perPage changes we write the cookie so a chosen density survives
|
|
16
|
-
* navigation. The URL always wins.
|
|
17
|
-
*/
|
|
18
|
-
export function useListUrlState(columns: Ref<ListColumn[]>) {
|
|
19
|
-
const route = useRoute()
|
|
20
|
-
const router = useRouter()
|
|
21
|
-
const perPageCookie = useCookie<number>('kestrel-list-per-page', { default: () => DEFAULT_PER_PAGE })
|
|
22
|
-
|
|
23
|
-
const parsed = computed(() => parseListQuery(route.query, columns.value))
|
|
24
|
-
const sort = computed(() => parsed.value.sort ?? '-createdAt')
|
|
25
|
-
const page = computed(() => parsed.value.page ?? 1)
|
|
26
|
-
const perPage = computed(() => parsed.value.perPage ?? clampPerPage(perPageCookie.value)) // URL wins, else cookie
|
|
27
|
-
const filter = computed(() => parsed.value.filter ?? {})
|
|
28
|
-
// The exact `$fetch` query the list endpoint expects — always the SANITIZED computeds, never the raw URL.
|
|
29
|
-
const effectiveQuery = computed(() => toQuery({ sort: sort.value, page: page.value, perPage: perPage.value, filter: filter.value }))
|
|
30
|
-
|
|
31
|
-
// We replace the WHOLE query on every navigation, so re-merge `locale` from the RAW URL (it lives outside
|
|
32
|
-
// the list-state keys and must survive a sort/page/filter change). Reading `route.query.locale` — NOT a
|
|
33
|
-
// resolved prop — keeps a locale-LESS URL locale-less after a list mutation: for a translatable collection
|
|
34
|
-
// the prop is the PRIMARY locale even when the URL carries no `?locale`, so merging it would inject
|
|
35
|
-
// `locale=<primary>`, flip the host page's `${collection}::${locale}` key and needlessly REMOUNT the list
|
|
36
|
-
// mid-edit. The locale switcher still navigates with an explicit `?locale`, which is preserved here (and
|
|
37
|
-
// correctly re-keys → remounts on a real locale switch).
|
|
38
|
-
function go(patch: Partial<ListState>, mode: 'push' | 'replace') {
|
|
39
|
-
const next: ListState = { sort: sort.value, page: page.value, perPage: perPage.value, filter: filter.value, ...patch }
|
|
40
|
-
const locale = route.query.locale
|
|
41
|
-
return router[mode]({ query: { ...toQuery(next), ...(typeof locale === 'string' ? { locale } : {}) } })
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const setSort = (f: string) => go({ sort: toggleSort(sort.value, f), page: 1 }, 'replace')
|
|
45
|
-
const setPage = (p: number) => go({ page: p }, 'push')
|
|
46
|
-
// Corrective clamp (a page that emptied out from under us) — REPLACE, not push, so the now-invalid page
|
|
47
|
-
// doesn't stay a reachable history entry that Back bounces the user forward from on every press.
|
|
48
|
-
const clampPage = (p: number) => go({ page: p }, 'replace')
|
|
49
|
-
const setPerPage = (n: number) => { perPageCookie.value = clampPerPage(n); return go({ perPage: clampPerPage(n), page: 1 }, 'replace') }
|
|
50
|
-
const setFilter = (f: Record<string, FilterCell>) => go({ filter: f, page: 1 }, 'replace')
|
|
51
|
-
|
|
52
|
-
return { sort, page, perPage, filter, effectiveQuery, setSort, setPage, clampPage, setPerPage, setFilter }
|
|
53
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { ref } from 'vue'
|
|
2
|
-
|
|
3
|
-
// A create saves, then the host navigates the editor to the new record's own URL (`/new` → `/<id>`), which
|
|
4
|
-
// tears down the editor that issued the save and remounts a fresh one at the real id. The republish it
|
|
5
|
-
// kicked off is still in flight, so the ARRIVING editor must POLL the live status (not do a single refresh)
|
|
6
|
-
// to catch the "Generating…" → "Live" transition. This module-scoped flag carries that intent across the
|
|
7
|
-
// remount: the departing instance sets it on save, the arriving instance reads-and-clears it on mount.
|
|
8
|
-
// Client-only ephemeral signal (set in a user save handler, read on mount) — a plain ref is enough; it never
|
|
9
|
-
// runs during SSR, so there is no cross-request leakage.
|
|
10
|
-
const pending = ref(false)
|
|
11
|
-
|
|
12
|
-
/** Shared "the next editor mount should poll the live status" flag — see the note above. */
|
|
13
|
-
export function usePendingPublishPoll() {
|
|
14
|
-
return pending
|
|
15
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { computed } from 'vue'
|
|
2
|
-
import type { Dim } from '../utils/preview-viewport'
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Persisted page-builder preview viewport: the target width/height as `Dim`s (a fixed px, or `'auto'`
|
|
6
|
-
* which fills the pane). Cookie-backed so the choice survives reloads and stays SSR-safe (server + client
|
|
7
|
-
* read the same value — no hydration mismatch), the same pattern as `useRailCollapsed`. Defaults to the
|
|
8
|
-
* config-driven desktop width with an `'auto'` height (fills the pane, no container scroll).
|
|
9
|
-
*
|
|
10
|
-
* Each setter reassigns the whole object (rather than mutating a field) so Nuxt's cookie watcher always
|
|
11
|
-
* re-serialises. Note: the seeded default only applies when the cookie is absent — if a project later
|
|
12
|
-
* changes `preview.desktopWidth`, an existing cookie keeps its `w` until the Desktop preset is clicked. A
|
|
13
|
-
* stale cookie from before Auto height (a numeric `h`, plus a now-ignored `fit`) is tolerated: it just
|
|
14
|
-
* reads as a fixed custom height until Desktop is re-selected.
|
|
15
|
-
*/
|
|
16
|
-
export interface PreviewViewport {
|
|
17
|
-
w: Dim
|
|
18
|
-
h: Dim
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function usePreviewViewport(desktopWidth: number) {
|
|
22
|
-
const state = useCookie<PreviewViewport>('kestrel-preview-viewport', {
|
|
23
|
-
default: () => ({ w: desktopWidth, h: 'auto' }),
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
const width = computed({ get: () => state.value.w, set: (v: Dim) => { state.value = { ...state.value, w: v } } })
|
|
27
|
-
const height = computed({ get: () => state.value.h, set: (v: Dim) => { state.value = { ...state.value, h: v } } })
|
|
28
|
-
|
|
29
|
-
return { width, height }
|
|
30
|
-
}
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import { ref, toValue } from 'vue'
|
|
2
|
-
import type { MaybeRefOrGetter } from 'vue'
|
|
3
|
-
|
|
4
|
-
/** The live publish state of a record's static page, as returned by `GET /api/publishStatus`. */
|
|
5
|
-
export interface PublishStatusData {
|
|
6
|
-
route: string | null
|
|
7
|
-
status: 'success' | 'error' | null
|
|
8
|
-
error?: string | null
|
|
9
|
-
updatedAt?: string | null
|
|
10
|
-
/** Which output the last attempt wrote to — `'local'` dir or `'s3'` bucket; null when no row yet. */
|
|
11
|
-
target?: 'local' | 's3' | null
|
|
12
|
-
/** The record was saved after its page was last published — the live file is an older version of it.
|
|
13
|
-
* The normal working state while editing, since a save no longer republishes (ADR-0008). */
|
|
14
|
-
pending?: boolean
|
|
15
|
-
/** The consumer opted out of the save/publish split (`output.publishOnSave`): a save republishes on its
|
|
16
|
-
* own, so the editor offers no Publish button and never reports unpublished changes. */
|
|
17
|
-
publishOnSave?: boolean
|
|
18
|
-
/** The page has no publish row at all. Distinct from "a publish is in flight": with the split on, a save
|
|
19
|
-
* enqueues nothing, so this state only ends when someone presses Publish. */
|
|
20
|
-
neverPublished?: boolean
|
|
21
|
-
/** Whether the runtime publisher actually produces files in THIS environment (prod + `output.auto`).
|
|
22
|
-
* `false` in dev / with static output off → a page can never turn "Live" here, so don't poll for it. */
|
|
23
|
-
generates?: boolean
|
|
24
|
-
/** The configured output destination (where the NEXT publish would go), independent of any row. */
|
|
25
|
-
driver?: 'local' | 's3'
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const EMPTY: PublishStatusData = { route: null, status: null }
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Fetch (and refetch) the LIVE / generated publish state of the record the editor is on, for the right
|
|
32
|
-
* dot of the editor Ampel. Explicit `refresh()` (no auto-fetch) so it is testable in isolation and the
|
|
33
|
-
* caller controls timing: the editor refreshes on mount and again after each save (a save may (re)publish
|
|
34
|
-
* the page). Skips the request entirely when disabled (non-pageLike collection) or the record is unsaved
|
|
35
|
-
* (`id === 'new'` has no server row, hence no route yet). A failed fetch degrades to an empty status
|
|
36
|
-
* rather than surfacing an error — the ampel is a non-critical signal.
|
|
37
|
-
*/
|
|
38
|
-
export function usePublishStatus(args: {
|
|
39
|
-
collection: MaybeRefOrGetter<string>
|
|
40
|
-
id: MaybeRefOrGetter<string | number>
|
|
41
|
-
locale?: MaybeRefOrGetter<string | undefined>
|
|
42
|
-
enabled?: MaybeRefOrGetter<boolean>
|
|
43
|
-
}) {
|
|
44
|
-
const data = ref<PublishStatusData>({ ...EMPTY })
|
|
45
|
-
// Bumped by every `refresh()` / `refreshUntilSettled()` start; an in-flight poll compares the token it
|
|
46
|
-
// captured and bails the moment a newer call supersedes it (navigation, another save), so stale loops
|
|
47
|
-
// can't keep writing to `data`.
|
|
48
|
-
let pollToken = 0
|
|
49
|
-
|
|
50
|
-
async function fetchOnce(): Promise<void> {
|
|
51
|
-
// Guard every write by the token captured at call time: if a newer refresh/poll supersedes this one
|
|
52
|
-
// while its request is in flight, its (possibly stale) response must NOT overwrite the fresher data —
|
|
53
|
-
// two overlapping fetches (a rapid re-save superseding a poll) can otherwise resolve out of order.
|
|
54
|
-
const token = pollToken
|
|
55
|
-
const enabled = args.enabled === undefined ? true : toValue(args.enabled)
|
|
56
|
-
const id = toValue(args.id)
|
|
57
|
-
if (!enabled || id === 'new' || id == null) {
|
|
58
|
-
if (token === pollToken) data.value = { ...EMPTY }
|
|
59
|
-
return
|
|
60
|
-
}
|
|
61
|
-
try {
|
|
62
|
-
const res = await $fetch<PublishStatusData>('/api/publishStatus', {
|
|
63
|
-
query: { collection: toValue(args.collection), id, locale: toValue(args.locale) },
|
|
64
|
-
})
|
|
65
|
-
if (token === pollToken) data.value = res
|
|
66
|
-
} catch {
|
|
67
|
-
if (token === pollToken) data.value = { ...EMPTY }
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** One-shot fetch. Also cancels any running poll (this is the fresh source of truth). */
|
|
72
|
-
async function refresh(): Promise<void> {
|
|
73
|
-
pollToken++
|
|
74
|
-
await fetchOnce()
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Poll the live status until it SETTLES, for the right lamp after a save: in prod a save enqueues an
|
|
79
|
-
* async (debounced) republish that finishes a moment later, so a single refresh would only ever catch the
|
|
80
|
-
* pre-republish row. Stops when the record has no route, the environment doesn't generate (`generates ===
|
|
81
|
-
* false` — dev / static output off: nothing will ever be produced, so polling is pointless), or a terminal
|
|
82
|
-
* outcome (`success`/`error`) from the NEW republish lands.
|
|
83
|
-
*
|
|
84
|
-
* `since` is the row's `updatedAt` BEFORE this save. `publish_status` is a latest-state upsert that is NOT
|
|
85
|
-
* cleared first, so a re-saved page keeps its prior success/error row until the fresh render rewrites it —
|
|
86
|
-
* without this baseline the very first poll would read that stale row and settle instantly (e.g. staying
|
|
87
|
-
* green "Live" for a re-save that actually failed to republish). So a terminal outcome only counts as
|
|
88
|
-
* settled once its `updatedAt` differs from `since`. Bounded by `attempts`; superseded-safe via `pollToken`.
|
|
89
|
-
*/
|
|
90
|
-
async function refreshUntilSettled(opts?: { attempts?: number; delayMs?: number; since?: string | null }): Promise<void> {
|
|
91
|
-
const token = ++pollToken
|
|
92
|
-
const attempts = opts?.attempts ?? 12
|
|
93
|
-
const delayMs = opts?.delayMs ?? 2000
|
|
94
|
-
const since = opts?.since ?? null
|
|
95
|
-
for (let i = 0; i < attempts; i++) {
|
|
96
|
-
if (token !== pollToken) return
|
|
97
|
-
await fetchOnce()
|
|
98
|
-
if (token !== pollToken) return
|
|
99
|
-
const d = data.value
|
|
100
|
-
const settled = (d.status === 'success' || d.status === 'error') && (d.updatedAt ?? null) !== since
|
|
101
|
-
if (!d.route || d.generates === false || settled) return
|
|
102
|
-
if (i < attempts - 1) await new Promise((resolve) => setTimeout(resolve, delayMs))
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
return { data, refresh, refreshUntilSettled }
|
|
107
|
-
}
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Collapsed/expanded state for the admin navigation rail.
|
|
3
|
-
* Backed by a cookie so the choice persists across reloads and stays SSR-safe
|
|
4
|
-
* (no hydration mismatch — server and client read the same value).
|
|
5
|
-
*/
|
|
6
|
-
export function useRailCollapsed() {
|
|
7
|
-
const collapsed = useCookie<boolean>('kestrel-rail-collapsed', { default: () => false })
|
|
8
|
-
|
|
9
|
-
function toggle() {
|
|
10
|
-
collapsed.value = !collapsed.value
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
return { collapsed, toggle }
|
|
14
|
-
}
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
export type ThemeName = 'light' | 'dark'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* The initial admin theme: an explicit stored choice wins; otherwise follow the OS
|
|
5
|
-
* `prefers-color-scheme`; default to light. Pure so it can be unit-tested without a DOM.
|
|
6
|
-
*/
|
|
7
|
-
export function resolveInitialTheme(opts: { stored?: string | null; prefersDark?: boolean }): ThemeName {
|
|
8
|
-
if (opts.stored === 'light' || opts.stored === 'dark') return opts.stored
|
|
9
|
-
return opts.prefersDark ? 'dark' : 'light'
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* Admin colour theme (light/dark). The explicit choice is cookie-backed so it persists and is
|
|
14
|
-
* SSR-safe (server and client read the same value — no hydration mismatch). Until a choice is made
|
|
15
|
-
* the theme follows the OS preference, picked up client-side. The admin layout binds `theme` onto
|
|
16
|
-
* `<html data-theme>`; `_tokens.scss` keys the palette off that attribute.
|
|
17
|
-
*/
|
|
18
|
-
export function useTheme() {
|
|
19
|
-
const stored = useCookie<ThemeName | null>('kestrel-admin-theme', { default: () => null })
|
|
20
|
-
const prefersDark = ref(false)
|
|
21
|
-
let mq: MediaQueryList | null = null
|
|
22
|
-
const onChange = (e: MediaQueryListEvent) => { prefersDark.value = e.matches }
|
|
23
|
-
|
|
24
|
-
onMounted(() => {
|
|
25
|
-
mq = window.matchMedia('(prefers-color-scheme: dark)')
|
|
26
|
-
prefersDark.value = mq.matches
|
|
27
|
-
mq.addEventListener('change', onChange)
|
|
28
|
-
})
|
|
29
|
-
onUnmounted(() => mq?.removeEventListener('change', onChange))
|
|
30
|
-
|
|
31
|
-
const theme = computed<ThemeName>(() => resolveInitialTheme({ stored: stored.value, prefersDark: prefersDark.value }))
|
|
32
|
-
const setTheme = (t: ThemeName) => { stored.value = t }
|
|
33
|
-
const toggle = () => setTheme(theme.value === 'dark' ? 'light' : 'dark')
|
|
34
|
-
|
|
35
|
-
return { theme, toggle, setTheme }
|
|
36
|
-
}
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
// Toolbar disclosure panels (filter / columns). Plain inline panels, not teleported, so they stay
|
|
2
|
-
// testable and keep their scoped styles; a document listener closes them on an outside click.
|
|
3
|
-
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
|
4
|
-
|
|
5
|
-
export function useToolbarPanel<T extends string>() {
|
|
6
|
-
const container = ref<HTMLElement | null>(null)
|
|
7
|
-
const open = ref<T | null>(null)
|
|
8
|
-
// The element that opened the current panel — Escape restores focus to it (otherwise closing the panel
|
|
9
|
-
// from inside drops keyboard focus onto <body>, stranding the keyboard user at the top of the document).
|
|
10
|
-
let trigger: HTMLElement | null = null
|
|
11
|
-
|
|
12
|
-
function toggle(which: T, e: MouseEvent) {
|
|
13
|
-
if (open.value === which) { open.value = null; return }
|
|
14
|
-
trigger = e.currentTarget as HTMLElement
|
|
15
|
-
open.value = which
|
|
16
|
-
}
|
|
17
|
-
function onDocPointer(e: PointerEvent) {
|
|
18
|
-
// Outside click: close but do NOT steal focus — it belongs wherever the user just clicked.
|
|
19
|
-
if (container.value && !container.value.contains(e.target as Node)) open.value = null
|
|
20
|
-
}
|
|
21
|
-
function onDocKeydown(e: KeyboardEvent) {
|
|
22
|
-
if (e.key === 'Escape' && open.value) {
|
|
23
|
-
open.value = null
|
|
24
|
-
trigger?.focus() // return focus to the trigger, per the disclosure/APG pattern
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
onMounted(() => {
|
|
29
|
-
document.addEventListener('pointerdown', onDocPointer)
|
|
30
|
-
document.addEventListener('keydown', onDocKeydown)
|
|
31
|
-
})
|
|
32
|
-
onBeforeUnmount(() => {
|
|
33
|
-
document.removeEventListener('pointerdown', onDocPointer)
|
|
34
|
-
document.removeEventListener('keydown', onDocKeydown)
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
return { container, open, toggle }
|
|
38
|
-
}
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Confirm before navigating away from unsaved editor changes. Registers BOTH route guards on purpose:
|
|
5
|
-
* `onBeforeRouteLeave` fires when the matched route record is dropped (editor → list, rail nav), but
|
|
6
|
-
* Vue Router fires `onBeforeRouteUpdate` instead when a param/query-only change reuses the SAME record —
|
|
7
|
-
* which is exactly what the LocaleBar does (edit a sibling translation `/admin/{coll}/{id}`,
|
|
8
|
-
* create-and-link `/admin/{coll}/new?…`, switch a singleton's `?locale`). Without the update guard those
|
|
9
|
-
* same-record transitions silently discard in-progress edits (the `key: route.fullPath` remount only
|
|
10
|
-
* happens AFTER the router has already committed the navigation, so it can't veto it).
|
|
11
|
-
*
|
|
12
|
-
* `isDirty` / `skip` / `message` are read lazily on each navigation so the guard always sees current
|
|
13
|
-
* state. Returning `false` from a guard cancels the navigation; `skip()` lets a post-save / delete
|
|
14
|
-
* redirect pass straight through.
|
|
15
|
-
*/
|
|
16
|
-
export function useUnsavedGuard(isDirty: () => boolean, message: () => string, skip: () => boolean = () => false): void {
|
|
17
|
-
const guard = () => {
|
|
18
|
-
if (!skip() && isDirty() && !confirm(message())) return false
|
|
19
|
-
}
|
|
20
|
-
onBeforeRouteLeave(guard)
|
|
21
|
-
onBeforeRouteUpdate(guard)
|
|
22
|
-
}
|