@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,107 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
// Pruvious-style content-locale switcher for the record editor. Shows every configured website locale:
|
|
3
|
-
// the active one as a label, an existing sibling as a pen (edit) + copy control, a missing one as a
|
|
4
|
-
// "+" (create) link. Content locales only — not the admin-UI language.
|
|
5
|
-
const props = defineProps<{
|
|
6
|
-
collection: string
|
|
7
|
-
id: string
|
|
8
|
-
mode: 'single' | 'multi'
|
|
9
|
-
current: string
|
|
10
|
-
translations: Record<string, number | null>
|
|
11
|
-
group?: string
|
|
12
|
-
}>()
|
|
13
|
-
const emit = defineEmits<{ copyFrom: [locale: string] }>()
|
|
14
|
-
|
|
15
|
-
const { locales } = useContentLocales()
|
|
16
|
-
|
|
17
|
-
/** Existing sibling row id for a locale (multi only), or null when there is no translation yet. */
|
|
18
|
-
function existingId(loc: string): number | null {
|
|
19
|
-
const v = props.translations[loc]
|
|
20
|
-
return typeof v === 'number' ? v : null
|
|
21
|
-
}
|
|
22
|
-
function createLink(loc: string): string {
|
|
23
|
-
const q = new URLSearchParams({ locale: loc })
|
|
24
|
-
if (props.group) q.set('group', props.group)
|
|
25
|
-
return `/admin/${props.collection}/new?${q.toString()}`
|
|
26
|
-
}
|
|
27
|
-
const up = (s: string) => s.toUpperCase()
|
|
28
|
-
const { t } = useT()
|
|
29
|
-
</script>
|
|
30
|
-
|
|
31
|
-
<template>
|
|
32
|
-
<div class="locale-bar" role="group" :aria-label="t('localeBar.groupLabel')">
|
|
33
|
-
<template v-for="loc in locales" :key="loc">
|
|
34
|
-
<span v-if="loc === current" class="locale-bar__item locale-bar__item--active" aria-current="true">{{ up(loc) }}</span>
|
|
35
|
-
|
|
36
|
-
<NuxtLink
|
|
37
|
-
v-else-if="mode === 'single'"
|
|
38
|
-
class="locale-bar__item locale-bar__btn"
|
|
39
|
-
:to="`/admin/${collection}?locale=${loc}`"
|
|
40
|
-
:aria-label="t('localeBar.editLocale', { loc: up(loc) })"
|
|
41
|
-
>{{ up(loc) }}<KestrelUiIcon name="pencil" :size="14" /></NuxtLink>
|
|
42
|
-
|
|
43
|
-
<span v-else-if="existingId(loc) !== null" class="locale-bar__item">
|
|
44
|
-
<!-- No `?locale=` here: the target id already IS a specific locale's row (useEditForm reads the
|
|
45
|
-
active locale off the loaded record, not the query, once an id is known). -->
|
|
46
|
-
<NuxtLink class="locale-bar__btn" :to="`/admin/${collection}/${existingId(loc)}`" :aria-label="t('localeBar.editLocale', { loc: up(loc) })">{{ up(loc) }}<KestrelUiIcon name="pencil" :size="14" /></NuxtLink>
|
|
47
|
-
<button type="button" class="locale-bar__btn locale-bar__btn--copy" :aria-label="t('localeBar.copyInto', { loc: up(loc), current: up(current) })" @click="emit('copyFrom', loc)"><KestrelUiIcon name="copy" :size="14" /></button>
|
|
48
|
-
</span>
|
|
49
|
-
|
|
50
|
-
<NuxtLink
|
|
51
|
-
v-else
|
|
52
|
-
class="locale-bar__item locale-bar__btn locale-bar__btn--add"
|
|
53
|
-
:to="createLink(loc)"
|
|
54
|
-
:aria-label="t('localeBar.createTranslation', { loc: up(loc) })"
|
|
55
|
-
>{{ up(loc) }}<KestrelUiIcon name="plus" :size="14" /></NuxtLink>
|
|
56
|
-
</template>
|
|
57
|
-
</div>
|
|
58
|
-
</template>
|
|
59
|
-
|
|
60
|
-
<style lang="scss">
|
|
61
|
-
.locale-bar {
|
|
62
|
-
display: flex;
|
|
63
|
-
align-items: center;
|
|
64
|
-
gap: var(--space-2);
|
|
65
|
-
|
|
66
|
-
&__item {
|
|
67
|
-
display: inline-flex;
|
|
68
|
-
align-items: center;
|
|
69
|
-
gap: var(--space-1);
|
|
70
|
-
font-size: var(--text-sm);
|
|
71
|
-
font-weight: var(--weight-medium);
|
|
72
|
-
|
|
73
|
-
&--active {
|
|
74
|
-
padding: var(--space-1) var(--space-2);
|
|
75
|
-
border-radius: var(--radius-sm);
|
|
76
|
-
background: var(--color-surface);
|
|
77
|
-
border: 1px solid var(--color-border);
|
|
78
|
-
color: var(--color-text);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
&__btn {
|
|
82
|
-
display: inline-flex;
|
|
83
|
-
align-items: center;
|
|
84
|
-
gap: var(--space-1);
|
|
85
|
-
padding: var(--space-1) var(--space-2);
|
|
86
|
-
border: 1px solid var(--color-border);
|
|
87
|
-
border-radius: var(--radius-sm);
|
|
88
|
-
background: transparent;
|
|
89
|
-
color: var(--color-text-muted);
|
|
90
|
-
text-decoration: none;
|
|
91
|
-
cursor: pointer;
|
|
92
|
-
|
|
93
|
-
&:hover {
|
|
94
|
-
border-color: var(--color-text-muted);
|
|
95
|
-
color: var(--color-text);
|
|
96
|
-
}
|
|
97
|
-
&--add {
|
|
98
|
-
border-style: solid;
|
|
99
|
-
border-color: var(--color-border);
|
|
100
|
-
color: var(--color-text-muted);
|
|
101
|
-
}
|
|
102
|
-
&--copy {
|
|
103
|
-
padding: var(--space-1);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
</style>
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import type { FieldDef, LayoutNode, SeoMeta } from '@michaelthielemann/kestrel-core'
|
|
3
|
-
import { slugify } from '@michaelthielemann/kestrel-core/client'
|
|
4
|
-
// The collection (page) field list — delegates to the shared <KestrelFieldLayout> renderer (rows / groups /
|
|
5
|
-
// widths, or one field per row when the collection declares no layout), shared by the flat-collection
|
|
6
|
-
// form and the 3-pane editor's root-selected pane so the wiring lives in exactly one place. pageLike
|
|
7
|
-
// collections also get their routable `path` (slug); seo-enabled ones get the SEO section — both as
|
|
8
|
-
// page-level system fields bracketing the collection fields.
|
|
9
|
-
const props = defineProps<{
|
|
10
|
-
fields: Record<string, FieldDef>
|
|
11
|
-
/** Normalized admin editor layout (from the serialized collection). Absent → one field per row. */
|
|
12
|
-
fieldLayout?: LayoutNode[]
|
|
13
|
-
values: Record<string, unknown>
|
|
14
|
-
errors: Record<string, string>
|
|
15
|
-
/** Root field keys holding a stale reference — shown as a non-blocking warning note. */
|
|
16
|
-
deadFields?: Set<string>
|
|
17
|
-
locale: string
|
|
18
|
-
pageLike?: boolean
|
|
19
|
-
seo?: boolean
|
|
20
|
-
status?: boolean
|
|
21
|
-
disabled?: boolean
|
|
22
|
-
}>()
|
|
23
|
-
const emit = defineEmits<{ update: [name: string, value: unknown] }>()
|
|
24
|
-
const { t } = useT()
|
|
25
|
-
|
|
26
|
-
// A project with a single layout has nothing to choose, so the control stays out of the pane entirely
|
|
27
|
-
// rather than offering one dead option.
|
|
28
|
-
const layoutOptions = computed(() => layoutSelectOptions(useOfferableLayouts(), t('pageSettings.layoutDefault')))
|
|
29
|
-
const showLayout = computed(() => !!props.pageLike && layoutOptions.value.length > 1)
|
|
30
|
-
|
|
31
|
-
// Article metadata is an installation-wide policy (`kestrel.seo.articleMeta`), not a per-collection one,
|
|
32
|
-
// so it comes from runtimeConfig rather than the serialized collection schema.
|
|
33
|
-
const articleMeta = computed(() => (useRuntimeConfig().public as { seoArticleMeta?: boolean }).seoArticleMeta === true)
|
|
34
|
-
|
|
35
|
-
// Live preview of the slug the server will auto-generate from the title while the field is left blank
|
|
36
|
-
// (the server slugifies the title on save). Falls back to '/' when there is no title yet.
|
|
37
|
-
const slugPlaceholder = computed(() => {
|
|
38
|
-
const s = slugify(recordTitle(props.fields, props.values))
|
|
39
|
-
return s ? `/${s}` : '/'
|
|
40
|
-
})
|
|
41
|
-
</script>
|
|
42
|
-
|
|
43
|
-
<template>
|
|
44
|
-
<!-- Publish gate (the `status` system column). The primary page-level control, so it leads the pane:
|
|
45
|
-
Draft keeps the page out of the generated site; Published renders + auto-publishes it. -->
|
|
46
|
-
<KestrelUiField
|
|
47
|
-
v-if="status"
|
|
48
|
-
class="page-settings__status"
|
|
49
|
-
:label="t('pageSettings.statusLabel')"
|
|
50
|
-
:hint="t('pageSettings.statusHint')"
|
|
51
|
-
>
|
|
52
|
-
<template #default="f">
|
|
53
|
-
<KestrelUiSelect
|
|
54
|
-
:model-value="(values.status as string) ?? 'draft'"
|
|
55
|
-
:options="[
|
|
56
|
-
{ label: t('pageSettings.statusDraft'), value: 'draft' },
|
|
57
|
-
{ label: t('pageSettings.statusPublished'), value: 'published' },
|
|
58
|
-
]"
|
|
59
|
-
:disabled="disabled"
|
|
60
|
-
v-bind="f"
|
|
61
|
-
@update:model-value="(v) => emit('update', 'status', v)"
|
|
62
|
-
/>
|
|
63
|
-
</template>
|
|
64
|
-
</KestrelUiField>
|
|
65
|
-
|
|
66
|
-
<!-- The collection (page) fields, laid out by the shared renderer (rows / groups / widths, or one field
|
|
67
|
-
per row when the collection declares no `fieldLayout`). The condition gate + dead-ref note live inside. -->
|
|
68
|
-
<KestrelFieldLayout
|
|
69
|
-
:layout="fieldLayout"
|
|
70
|
-
:fields="fields"
|
|
71
|
-
:values="values"
|
|
72
|
-
:errors="errors"
|
|
73
|
-
:dead-fields="deadFields"
|
|
74
|
-
:locale="locale"
|
|
75
|
-
:disabled="disabled"
|
|
76
|
-
@update="(name, value) => emit('update', name, value)"
|
|
77
|
-
/>
|
|
78
|
-
|
|
79
|
-
<!-- Page slug (the routable path). A system column on pageLike collections, edited like a field. -->
|
|
80
|
-
<KestrelUiField
|
|
81
|
-
v-if="pageLike"
|
|
82
|
-
class="page-settings__slug"
|
|
83
|
-
:label="t('pageSettings.slugLabel')"
|
|
84
|
-
:hint="t('pageSettings.slugHint')"
|
|
85
|
-
:error="errors.path || null"
|
|
86
|
-
>
|
|
87
|
-
<template #default="f">
|
|
88
|
-
<KestrelUiTextInput
|
|
89
|
-
:model-value="(values.path as string) ?? ''"
|
|
90
|
-
:disabled="disabled"
|
|
91
|
-
:placeholder="slugPlaceholder"
|
|
92
|
-
v-bind="f"
|
|
93
|
-
@update:model-value="(v) => emit('update', 'path', v)"
|
|
94
|
-
/>
|
|
95
|
-
</template>
|
|
96
|
-
</KestrelUiField>
|
|
97
|
-
|
|
98
|
-
<!-- Which layout renders this page (the `layout` system column). Empty = the `default` layout, so an
|
|
99
|
-
unset value keeps rendering exactly as a project without the column. -->
|
|
100
|
-
<KestrelUiField
|
|
101
|
-
v-if="showLayout"
|
|
102
|
-
class="page-settings__layout"
|
|
103
|
-
:label="t('pageSettings.layoutLabel')"
|
|
104
|
-
:hint="t('pageSettings.layoutHint')"
|
|
105
|
-
:error="errors.layout || null"
|
|
106
|
-
>
|
|
107
|
-
<template #default="f">
|
|
108
|
-
<KestrelUiSelect
|
|
109
|
-
:model-value="(values.layout as string) ?? ''"
|
|
110
|
-
:options="layoutOptions"
|
|
111
|
-
:disabled="disabled"
|
|
112
|
-
v-bind="f"
|
|
113
|
-
@update:model-value="(v) => emit('update', 'layout', v)"
|
|
114
|
-
/>
|
|
115
|
-
</template>
|
|
116
|
-
</KestrelUiField>
|
|
117
|
-
|
|
118
|
-
<!-- Page SEO (meta title/description/noindex + Google preview). The `seo` JSON system column. -->
|
|
119
|
-
<KestrelSeoFields
|
|
120
|
-
v-if="seo"
|
|
121
|
-
:value="(values.seo as SeoMeta) ?? {}"
|
|
122
|
-
:page-title="(values.title as string) ?? ''"
|
|
123
|
-
:path="(values.path as string) ?? ''"
|
|
124
|
-
:locale="locale"
|
|
125
|
-
:disabled="disabled"
|
|
126
|
-
:article-meta="articleMeta"
|
|
127
|
-
@update="(v) => emit('update', 'seo', v)"
|
|
128
|
-
/>
|
|
129
|
-
</template>
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import type { PageFieldsBindings } from '../utils/editor-form-context'
|
|
3
|
-
|
|
4
|
-
// The page-fields fragment shared by both editor bodies: an optional content-locale switcher
|
|
5
|
-
// (labelled like a page field) sitting above the collection (page) field list. Centralises the
|
|
6
|
-
// LocaleBar + PageFields wiring so the flat form and the 3-pane editor's root pane stay in sync.
|
|
7
|
-
// Props are the shared `PageFieldsBindings` type — the same shape the shell pre-builds and provides.
|
|
8
|
-
defineProps<PageFieldsBindings>()
|
|
9
|
-
const emit = defineEmits<{ copyFrom: [locale: string]; update: [name: string, value: unknown] }>()
|
|
10
|
-
const { t } = useT()
|
|
11
|
-
</script>
|
|
12
|
-
|
|
13
|
-
<template>
|
|
14
|
-
<div v-if="translatable" class="ui-field">
|
|
15
|
-
<span class="ui-field__label">{{ t('localeBar.fieldLabel') }}</span>
|
|
16
|
-
<KestrelLocaleBar
|
|
17
|
-
:collection="collection"
|
|
18
|
-
:id="id"
|
|
19
|
-
:mode="mode"
|
|
20
|
-
:current="locale"
|
|
21
|
-
:translations="translations"
|
|
22
|
-
:group="group"
|
|
23
|
-
@copy-from="(loc) => emit('copyFrom', loc)"
|
|
24
|
-
/>
|
|
25
|
-
</div>
|
|
26
|
-
<KestrelPageFields
|
|
27
|
-
:fields="fields"
|
|
28
|
-
:field-layout="fieldLayout"
|
|
29
|
-
:values="values"
|
|
30
|
-
:errors="errors"
|
|
31
|
-
:dead-fields="deadFields"
|
|
32
|
-
:locale="locale"
|
|
33
|
-
:page-like="pageLike"
|
|
34
|
-
:seo="seo"
|
|
35
|
-
:status="status"
|
|
36
|
-
:disabled="disabled"
|
|
37
|
-
@update="(name, value) => emit('update', name, value)"
|
|
38
|
-
/>
|
|
39
|
-
</template>
|
|
@@ -1,210 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import { computed } from 'vue'
|
|
3
|
-
import { localePath } from '@michaelthielemann/kestrel-core/client'
|
|
4
|
-
import type { FieldDef, SeoMeta } from '@michaelthielemann/kestrel-core'
|
|
5
|
-
// The page-level SEO section: edit the meta title/description/noindex (the `seo` system column) with a
|
|
6
|
-
// live Google-result preview. Emits the whole merged SeoMeta so the parent routes it through setField.
|
|
7
|
-
const props = defineProps<{
|
|
8
|
-
value: SeoMeta
|
|
9
|
-
pageTitle?: string
|
|
10
|
-
path?: string
|
|
11
|
-
locale: string
|
|
12
|
-
disabled?: boolean
|
|
13
|
-
/** `kestrel.seo.articleMeta`. Off (the default) hides author/date/keywords entirely: an installation
|
|
14
|
-
* that must not attribute content should never be offered the fields, not merely stop publishing
|
|
15
|
-
* them. Stored values are hidden, never cleared — turning the flag back on restores them. */
|
|
16
|
-
articleMeta?: boolean
|
|
17
|
-
}>()
|
|
18
|
-
const emit = defineEmits<{ update: [seo: SeoMeta] }>()
|
|
19
|
-
const { t } = useT()
|
|
20
|
-
const { primary, prefixPrimary } = useContentLocales()
|
|
21
|
-
|
|
22
|
-
// Soft guidance limits (Google truncates ~ these lengths).
|
|
23
|
-
const TITLE_MAX = 60
|
|
24
|
-
const DESC_MAX = 160
|
|
25
|
-
|
|
26
|
-
function patch(next: Partial<SeoMeta>) {
|
|
27
|
-
emit('update', { ...props.value, ...next })
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const previewTitle = computed(() => props.value.title?.trim() || props.pageTitle?.trim() || t('seo.untitled'))
|
|
31
|
-
const previewDesc = computed(() => props.value.description?.trim() || t('seo.noDescription'))
|
|
32
|
-
|
|
33
|
-
const host = computed(() => {
|
|
34
|
-
try { return useRequestURL().host } catch { return '' }
|
|
35
|
-
})
|
|
36
|
-
const previewUrl = computed(() => `${host.value}${localePath(props.path || '/', props.locale, primary, prefixPrimary)}`)
|
|
37
|
-
|
|
38
|
-
const titleLen = computed(() => (props.value.title ?? '').length)
|
|
39
|
-
const descLen = computed(() => (props.value.description ?? '').length)
|
|
40
|
-
|
|
41
|
-
// The social-share image (og:image) rides the standard media widget over a synthetic single-media field
|
|
42
|
-
// def — same picker/thumb/remove UX as any media field, stored as `seo.image` (a media id).
|
|
43
|
-
const socialImageField = { type: 'media', options: { accept: 'image' } } as FieldDef
|
|
44
|
-
</script>
|
|
45
|
-
|
|
46
|
-
<template>
|
|
47
|
-
<div class="seo-fields">
|
|
48
|
-
<p class="seo-fields__section-label">{{ t('seo.sectionLabel') }}</p>
|
|
49
|
-
|
|
50
|
-
<!-- Google SERP-style preview. Decorative (it mirrors the inputs below) → hidden from SRs. -->
|
|
51
|
-
<div class="seo-preview" aria-hidden="true">
|
|
52
|
-
<div class="seo-preview__url">{{ previewUrl }}</div>
|
|
53
|
-
<div class="seo-preview__title">{{ previewTitle }}</div>
|
|
54
|
-
<div class="seo-preview__desc">{{ previewDesc }}</div>
|
|
55
|
-
</div>
|
|
56
|
-
|
|
57
|
-
<KestrelUiField class="seo-fields__title" :label="t('seo.metaTitle')" :hint="`${titleLen}/${TITLE_MAX}`">
|
|
58
|
-
<template #default="f">
|
|
59
|
-
<KestrelUiTextInput
|
|
60
|
-
:model-value="value.title ?? ''"
|
|
61
|
-
:placeholder="pageTitle"
|
|
62
|
-
:disabled="disabled"
|
|
63
|
-
v-bind="f"
|
|
64
|
-
@update:model-value="(v) => patch({ title: v ?? '' })"
|
|
65
|
-
/>
|
|
66
|
-
</template>
|
|
67
|
-
</KestrelUiField>
|
|
68
|
-
|
|
69
|
-
<KestrelUiField class="seo-fields__desc" :label="t('seo.metaDescription')" :hint="`${descLen}/${DESC_MAX}`">
|
|
70
|
-
<template #default="f">
|
|
71
|
-
<KestrelUiTextarea
|
|
72
|
-
:model-value="value.description ?? ''"
|
|
73
|
-
:rows="3"
|
|
74
|
-
:disabled="disabled"
|
|
75
|
-
v-bind="f"
|
|
76
|
-
@update:model-value="(v) => patch({ description: v ?? '' })"
|
|
77
|
-
/>
|
|
78
|
-
</template>
|
|
79
|
-
</KestrelUiField>
|
|
80
|
-
|
|
81
|
-
<KestrelUiField class="seo-fields__image" :label="t('seo.socialImage')" :hint="t('seo.socialImageHint')">
|
|
82
|
-
<template #default="f">
|
|
83
|
-
<KestrelFieldMedia
|
|
84
|
-
:field="socialImageField"
|
|
85
|
-
name="seoImage"
|
|
86
|
-
:locale="locale"
|
|
87
|
-
:disabled="disabled"
|
|
88
|
-
v-bind="f"
|
|
89
|
-
:model-value="value.image ?? null"
|
|
90
|
-
@update:model-value="(v) => patch({ image: typeof v === 'number' ? v : null })"
|
|
91
|
-
/>
|
|
92
|
-
</template>
|
|
93
|
-
</KestrelUiField>
|
|
94
|
-
|
|
95
|
-
<!-- Article metadata (schema.org author / datePublished / keywords). Opt-in per installation. -->
|
|
96
|
-
<template v-if="articleMeta">
|
|
97
|
-
<KestrelUiField class="seo-fields__author" :label="t('seo.author')" :hint="t('seo.authorHint')">
|
|
98
|
-
<template #default="f">
|
|
99
|
-
<KestrelUiTextInput
|
|
100
|
-
:model-value="value.author ?? ''"
|
|
101
|
-
:disabled="disabled"
|
|
102
|
-
v-bind="f"
|
|
103
|
-
@update:model-value="(v) => patch({ author: v ?? '' })"
|
|
104
|
-
/>
|
|
105
|
-
</template>
|
|
106
|
-
</KestrelUiField>
|
|
107
|
-
|
|
108
|
-
<KestrelUiField class="seo-fields__published" :label="t('seo.publishedDate')" :hint="t('seo.publishedDateHint')">
|
|
109
|
-
<template #default="f">
|
|
110
|
-
<KestrelUiDatePicker
|
|
111
|
-
:model-value="value.publishedDate || null"
|
|
112
|
-
precision="date"
|
|
113
|
-
:disabled="disabled"
|
|
114
|
-
:aria-label="t('seo.publishedDate')"
|
|
115
|
-
:describedby="f['aria-describedby']"
|
|
116
|
-
@update:model-value="(v) => patch({ publishedDate: v ?? '' })"
|
|
117
|
-
/>
|
|
118
|
-
</template>
|
|
119
|
-
</KestrelUiField>
|
|
120
|
-
|
|
121
|
-
<KestrelUiField class="seo-fields__keywords" :label="t('seo.keywords')" :hint="t('seo.keywordsHint')">
|
|
122
|
-
<template #default="f">
|
|
123
|
-
<KestrelUiTextInput
|
|
124
|
-
:model-value="value.keywords ?? ''"
|
|
125
|
-
:disabled="disabled"
|
|
126
|
-
v-bind="f"
|
|
127
|
-
@update:model-value="(v) => patch({ keywords: v ?? '' })"
|
|
128
|
-
/>
|
|
129
|
-
</template>
|
|
130
|
-
</KestrelUiField>
|
|
131
|
-
</template>
|
|
132
|
-
|
|
133
|
-
<!-- eslint-disable-next-line vuejs-accessibility/label-has-for -- native wrapping label around a custom UiCheckbox; no `for`/`id` pair needed, invisible to static analysis -->
|
|
134
|
-
<label class="seo-fields__noindex">
|
|
135
|
-
<KestrelUiCheckbox
|
|
136
|
-
:model-value="!!value.noindex"
|
|
137
|
-
:disabled="disabled"
|
|
138
|
-
@update:model-value="(v) => patch({ noindex: v })"
|
|
139
|
-
/>
|
|
140
|
-
<span>{{ t('seo.noindexLabel') }}</span>
|
|
141
|
-
</label>
|
|
142
|
-
</div>
|
|
143
|
-
</template>
|
|
144
|
-
|
|
145
|
-
<style lang="scss">
|
|
146
|
-
.seo-fields {
|
|
147
|
-
display: flex;
|
|
148
|
-
flex-direction: column;
|
|
149
|
-
gap: var(--space-4);
|
|
150
|
-
|
|
151
|
-
&__section-label {
|
|
152
|
-
margin: 0;
|
|
153
|
-
font-size: var(--text-sm);
|
|
154
|
-
font-weight: var(--weight-medium);
|
|
155
|
-
color: var(--color-text-muted);
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
&__noindex {
|
|
159
|
-
display: inline-flex;
|
|
160
|
-
align-items: center;
|
|
161
|
-
gap: var(--space-2);
|
|
162
|
-
font-size: var(--text-sm);
|
|
163
|
-
cursor: pointer;
|
|
164
|
-
|
|
165
|
-
input {
|
|
166
|
-
width: 1rem;
|
|
167
|
-
height: 1rem;
|
|
168
|
-
accent-color: var(--color-primary);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// A restrained take on a Google result card — enough to gauge truncation, not a pixel-perfect clone.
|
|
174
|
-
.seo-preview {
|
|
175
|
-
padding: var(--space-3);
|
|
176
|
-
border: 1px solid var(--color-border);
|
|
177
|
-
border-radius: var(--radius-md);
|
|
178
|
-
background: var(--color-surface);
|
|
179
|
-
|
|
180
|
-
&__url {
|
|
181
|
-
font-size: var(--text-xs, 0.75rem);
|
|
182
|
-
color: var(--color-text-muted);
|
|
183
|
-
white-space: nowrap;
|
|
184
|
-
overflow: hidden;
|
|
185
|
-
text-overflow: ellipsis;
|
|
186
|
-
}
|
|
187
|
-
&__title {
|
|
188
|
-
margin-top: var(--space-1);
|
|
189
|
-
font-size: var(--text-lg);
|
|
190
|
-
line-height: 1.3;
|
|
191
|
-
color: var(--color-link, #1a0dab);
|
|
192
|
-
overflow: hidden;
|
|
193
|
-
text-overflow: ellipsis;
|
|
194
|
-
display: -webkit-box;
|
|
195
|
-
-webkit-line-clamp: 1;
|
|
196
|
-
line-clamp: 1;
|
|
197
|
-
-webkit-box-orient: vertical;
|
|
198
|
-
}
|
|
199
|
-
&__desc {
|
|
200
|
-
margin-top: var(--space-1);
|
|
201
|
-
font-size: var(--text-sm);
|
|
202
|
-
color: var(--color-text);
|
|
203
|
-
overflow: hidden;
|
|
204
|
-
display: -webkit-box;
|
|
205
|
-
-webkit-line-clamp: 2;
|
|
206
|
-
line-clamp: 2;
|
|
207
|
-
-webkit-box-orient: vertical;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
</style>
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
// A proper editor for a single-record (singleton) collection — the same header-driven chrome as the
|
|
3
|
-
// record editor, minus list/new/delete. The header carries the title and a Save button that submits the
|
|
4
|
-
// CollectionEditor's form by id; `saving`/`dirty` come from the editor's exposed state.
|
|
5
|
-
import type { EditorExpose } from '../utils/editor-expose'
|
|
6
|
-
|
|
7
|
-
defineProps<{ collection: string; title: string; localeParam?: string }>()
|
|
8
|
-
|
|
9
|
-
const { t } = useT()
|
|
10
|
-
const toast = useToast()
|
|
11
|
-
|
|
12
|
-
const EDITOR_FORM_ID = 'singleton-editor'
|
|
13
|
-
const editorRef = ref<EditorExpose | null>(null)
|
|
14
|
-
const saving = computed(() => editorRef.value?.saving ?? false)
|
|
15
|
-
|
|
16
|
-
function onSaved() {
|
|
17
|
-
toast.success(t('toast.saved'))
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
// Unsaved edits preview through a ticket instead of a save; the editor owns both paths (ADR-0008).
|
|
21
|
-
const previewTitle = computed(() => t(editorRef.value?.dirty ? 'editor.previewUnsaved' : 'editor.openInNewTab'))
|
|
22
|
-
|
|
23
|
-
// Guard in-app navigation away from unsaved changes — both record-change AND same-record `?locale`
|
|
24
|
-
// switches (the LocaleBar's singleton locale switch reuses this route record → onBeforeRouteUpdate).
|
|
25
|
-
// The editor itself guards native unload.
|
|
26
|
-
useUnsavedGuard(() => editorRef.value?.dirty ?? false, () => t('editor.discardConfirm'))
|
|
27
|
-
</script>
|
|
28
|
-
|
|
29
|
-
<template>
|
|
30
|
-
<section class="singleton">
|
|
31
|
-
<div class="singleton__head">
|
|
32
|
-
<h1 class="singleton__title">{{ title }}</h1>
|
|
33
|
-
<div class="singleton__actions">
|
|
34
|
-
<KestrelUiButton type="button" variant="ghost" size="sm" icon="undo" :disabled="saving || !editorRef?.canUndo" :title="t('history.undo')" :aria-label="t('history.undo')" @click="editorRef?.undo()" />
|
|
35
|
-
<KestrelUiButton type="button" variant="ghost" size="sm" icon="redo" :disabled="saving || !editorRef?.canRedo" :title="t('history.redo')" :aria-label="t('history.redo')" @click="editorRef?.redo()" />
|
|
36
|
-
<KestrelUiButton type="button" variant="ghost" size="sm" icon="external-link" :title="previewTitle" :aria-label="previewTitle" :loading="editorRef?.previewOpening" @click="editorRef?.openPreview()" />
|
|
37
|
-
<!-- A quarantined record is read-only: no Save, no Publish — there is nothing valid to write. -->
|
|
38
|
-
<KestrelUiButton v-if="!editorRef?.quarantined" type="submit" :form="EDITOR_FORM_ID" variant="primary" size="sm" icon="check" :loading="saving">{{ t('common.save') }}</KestrelUiButton>
|
|
39
|
-
<!-- Publishing is its own decision: Save persists, Publish writes the static page (ADR-0008). -->
|
|
40
|
-
<KestrelUiButton v-if="editorRef?.canPublish !== false && !editorRef?.quarantined" type="button" variant="secondary" size="sm" icon="upload" :loading="editorRef?.publishing" :disabled="saving" @click="editorRef?.publish()">{{ t('common.publish') }}</KestrelUiButton>
|
|
41
|
-
<KestrelEditorStatus class="singleton__ampel" :dirty="editorRef?.dirty ?? false" :saving="saving" :has-status="editorRef?.hasStatus ?? false" :status="editorRef?.status" :saved-status="editorRef?.savedStatus" :page-like="editorRef?.pageLike ?? false" :live="editorRef?.live" />
|
|
42
|
-
</div>
|
|
43
|
-
</div>
|
|
44
|
-
<KestrelCollectionEditor
|
|
45
|
-
ref="editorRef"
|
|
46
|
-
:collection="collection"
|
|
47
|
-
id="single"
|
|
48
|
-
:form-id="EDITOR_FORM_ID"
|
|
49
|
-
:locale-param="localeParam"
|
|
50
|
-
:actions="false"
|
|
51
|
-
@saved="onSaved"
|
|
52
|
-
/>
|
|
53
|
-
</section>
|
|
54
|
-
</template>
|
|
55
|
-
|
|
56
|
-
<style lang="scss">
|
|
57
|
-
// The singleton shell stays fixed and fills the height handed down; the editor body (.editor__flat /
|
|
58
|
-
// .editor3) owns the scroll — same model as the record editor (see the admin layout).
|
|
59
|
-
.singleton {
|
|
60
|
-
display: flex;
|
|
61
|
-
flex-direction: column;
|
|
62
|
-
gap: var(--space-4);
|
|
63
|
-
flex: 1 1 auto;
|
|
64
|
-
min-height: 0;
|
|
65
|
-
overflow: hidden;
|
|
66
|
-
|
|
67
|
-
// Fixed header; the fields scroll below it, so it needs no sticky positioning (matching .record__head).
|
|
68
|
-
&__head {
|
|
69
|
-
flex: 0 0 auto;
|
|
70
|
-
display: flex;
|
|
71
|
-
align-items: center;
|
|
72
|
-
justify-content: space-between;
|
|
73
|
-
gap: var(--space-4);
|
|
74
|
-
padding-bottom: var(--space-3);
|
|
75
|
-
border-bottom: 1px solid var(--color-border);
|
|
76
|
-
}
|
|
77
|
-
&__title {
|
|
78
|
-
font-size: var(--text-xl);
|
|
79
|
-
font-weight: var(--weight-bold);
|
|
80
|
-
text-transform: capitalize;
|
|
81
|
-
}
|
|
82
|
-
&__actions {
|
|
83
|
-
display: flex;
|
|
84
|
-
align-items: center;
|
|
85
|
-
gap: var(--space-2);
|
|
86
|
-
}
|
|
87
|
-
&__ampel {
|
|
88
|
-
margin-inline-start: var(--space-1);
|
|
89
|
-
padding-inline-start: var(--space-3);
|
|
90
|
-
border-inline-start: 1px solid var(--color-border);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
</style>
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
interface AuthState {
|
|
2
|
-
authenticated: boolean
|
|
3
|
-
exp: number | null
|
|
4
|
-
checked: boolean
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
export function useAuth() {
|
|
8
|
-
const state = useState<AuthState>('kestrel-auth', () => ({
|
|
9
|
-
authenticated: false,
|
|
10
|
-
exp: null,
|
|
11
|
-
checked: false,
|
|
12
|
-
}))
|
|
13
|
-
|
|
14
|
-
const expired = () => state.value.exp !== null && state.value.exp <= Date.now()
|
|
15
|
-
|
|
16
|
-
async function checkSession() {
|
|
17
|
-
// Fail closed: a transient session-endpoint failure (5xx, offline) must read as
|
|
18
|
-
// "not authenticated" so the route guard redirects to login instead of throwing an error page.
|
|
19
|
-
try {
|
|
20
|
-
const r = await $fetch<{ authenticated: boolean; exp?: number }>('/api/session')
|
|
21
|
-
state.value = { authenticated: r.authenticated, exp: r.exp ?? null, checked: true }
|
|
22
|
-
} catch {
|
|
23
|
-
state.value = { authenticated: false, exp: null, checked: true }
|
|
24
|
-
}
|
|
25
|
-
return state.value.authenticated
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
async function ensureSession() {
|
|
29
|
-
if (!state.value.checked || expired()) await checkSession()
|
|
30
|
-
return state.value.authenticated && !expired()
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
async function login(password: string) {
|
|
34
|
-
const r = await $fetch<{ ok: true; exp: number }>('/api/login', {
|
|
35
|
-
method: 'POST',
|
|
36
|
-
body: { password },
|
|
37
|
-
})
|
|
38
|
-
state.value = { authenticated: true, exp: r.exp, checked: true }
|
|
39
|
-
return r
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Drop the local session without a server round-trip — used by the re-auth interceptor on a 401. */
|
|
43
|
-
function reset() {
|
|
44
|
-
state.value = { authenticated: false, exp: null, checked: true }
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
async function logout() {
|
|
48
|
-
// The desired end state is "logged out" regardless of the response: an expired/rotated
|
|
49
|
-
// session makes the guarded logout endpoint 401, but the local session is still cleared.
|
|
50
|
-
try {
|
|
51
|
-
await $fetch('/api/logout', { method: 'POST' })
|
|
52
|
-
} catch {
|
|
53
|
-
// already invalid server-side — nothing to clear there
|
|
54
|
-
}
|
|
55
|
-
state.value = { authenticated: false, exp: null, checked: true }
|
|
56
|
-
await navigateTo('/admin/login')
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return {
|
|
60
|
-
authenticated: computed(() => state.value.authenticated && !expired()),
|
|
61
|
-
exp: computed(() => state.value.exp),
|
|
62
|
-
checkSession,
|
|
63
|
-
ensureSession,
|
|
64
|
-
login,
|
|
65
|
-
logout,
|
|
66
|
-
reset,
|
|
67
|
-
}
|
|
68
|
-
}
|