@michaelthielemann/kestrel 2.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -8
- package/layers/admin/app/components/AdminAccount.vue +3 -3
- package/layers/admin/app/components/AdminNav.vue +1 -1
- package/layers/admin/app/components/BlockFields.vue +2 -2
- package/layers/admin/app/components/BlockPreview.vue +8 -8
- package/layers/admin/app/components/BlockTree.vue +10 -10
- package/layers/admin/app/components/BlocksBody.vue +4 -4
- package/layers/admin/app/components/CollectionDeleteDialog.vue +7 -7
- package/layers/admin/app/components/CollectionEditor.vue +3 -3
- package/layers/admin/app/components/CollectionList.vue +91 -788
- package/layers/admin/app/components/CollectionListBulkBar.vue +48 -0
- package/layers/admin/app/components/CollectionListColumnsPanel.vue +37 -0
- package/layers/admin/app/components/CollectionListFilterPanel.vue +145 -0
- package/layers/admin/app/components/CollectionListPager.vue +70 -0
- package/layers/admin/app/components/CollectionListTable.vue +308 -0
- package/layers/admin/app/components/EditorStatus.vue +4 -4
- package/layers/admin/app/components/FieldsBody.vue +1 -1
- package/layers/admin/app/components/LocaleBar.vue +4 -4
- package/layers/admin/app/components/PageFields.vue +17 -12
- package/layers/admin/app/components/PageFieldsPane.vue +2 -2
- package/layers/admin/app/components/SeoFields.vue +52 -10
- package/layers/admin/app/components/SingletonEditor.vue +7 -7
- package/layers/admin/app/composables/useEditForm.ts +9 -3
- package/layers/admin/app/composables/useListBatchActions.ts +61 -0
- package/layers/admin/app/composables/useListFilterDraft.ts +128 -0
- package/layers/admin/app/composables/useListRows.ts +70 -0
- package/layers/admin/app/composables/useListSelection.ts +28 -0
- package/layers/admin/app/composables/useToolbarPanel.ts +38 -0
- package/layers/admin/app/layouts/admin.vue +7 -7
- package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +2 -2
- package/layers/admin/app/pages/admin/[collection]/[id].vue +11 -11
- package/layers/admin/app/pages/admin/[collection]/index.vue +2 -2
- package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +1 -1
- package/layers/admin/app/pages/admin/index.vue +3 -3
- package/layers/admin/app/pages/admin/login.vue +6 -6
- package/layers/admin/app/pages/admin/media.vue +1 -1
- package/layers/admin/app/pages/admin/references.vue +3 -3
- package/layers/admin/app/utils/edit-form.ts +9 -2
- package/layers/admin/app/utils/list-cell.ts +46 -0
- package/layers/admin/nuxt.config.ts +3 -0
- package/layers/core/modules/component-namespace/index.ts +20 -0
- package/layers/core/modules/component-namespace/shared.ts +24 -0
- package/layers/core/modules/kestrel/index.ts +11 -0
- package/layers/core/nuxt.config.ts +1 -0
- package/layers/core/server/api/[collection]/index.put.test.ts +76 -0
- package/layers/core/server/api/[collection]/index.put.ts +18 -1
- package/layers/core/server/utils/collection-types.ts +4 -3
- package/layers/core/server/utils/defineCollection.ts +8 -2
- package/layers/core/server/utils/kestrel-config.ts +35 -1
- package/layers/core/server/utils/seo.ts +18 -0
- package/layers/core/server/utils/serialize-collection.ts +0 -2
- package/layers/core/server/utils/write-effects.ts +40 -0
- package/layers/fields/app/utils/field-factories.ts +1 -1
- package/layers/fields/server/utils/buildCollection.ts +9 -7
- package/layers/media/app/components/KestrelImg.vue +29 -0
- package/layers/media/app/components/MediaConflictDialog.vue +7 -7
- package/layers/media/app/components/MediaDeleteDialog.vue +5 -5
- package/layers/media/app/components/MediaGrid.vue +3 -3
- package/layers/media/app/components/MediaLibrary.vue +26 -24
- package/layers/media/app/components/MediaNewFolderDialog.vue +7 -7
- package/layers/media/app/components/MediaPathBar.vue +1 -1
- package/layers/media/app/components/MediaPicker.vue +3 -3
- package/layers/media/app/components/MediaRenameDialog.vue +8 -8
- package/layers/media/app/components/MediaTable.vue +2 -2
- package/layers/media/app/components/MediaToolbar.vue +4 -4
- package/layers/media/app/components/MediaUploadDialog.vue +9 -9
- package/layers/media/app/components/MediaViewer.vue +64 -15
- package/layers/media/app/components/field/Media.vue +9 -9
- package/layers/media/app/plugins/field-media.client.ts +3 -3
- package/layers/media/app/utils/ai-disclosure.ts +19 -0
- package/layers/media/app/utils/library.ts +2 -0
- package/layers/media/nuxt.config.ts +5 -1
- package/layers/media/server/api/media/[id].patch.test.ts +66 -0
- package/layers/media/server/api/media/[id].patch.ts +25 -1
- package/layers/media/server/api/media/index.post.ts +19 -3
- package/layers/media/server/collections/media.ts +14 -0
- package/layers/media/server/utils/ai-disclosure-enabled.ts +16 -0
- package/layers/media/server/utils/ai-signal-detect.ts +155 -0
- package/layers/media/server/utils/library.ts +2 -1
- package/layers/media/server/utils/record.ts +8 -0
- package/layers/media/server/utils/resolve.ts +12 -0
- package/layers/public/app/pages/[...slug].vue +32 -3
- package/layers/public/app/pages/__kestrel/preview.vue +1 -1
- package/layers/public/app/utils/json-ld.ts +139 -0
- package/layers/public/modules/deploy-output/deploy-output.ts +19 -5
- package/layers/public/modules/prerender-routes/index.ts +5 -2
- package/layers/public/nuxt.config.ts +3 -0
- package/layers/public/server/api/route.get.ts +9 -1
- package/layers/public/server/collections/redirects.ts +75 -0
- package/layers/public/server/plugins/03.redirects.ts +37 -0
- package/layers/public/server/routes/llms-full.txt.get.ts +99 -0
- package/layers/public/server/routes/llms.txt.get.ts +1 -12
- package/layers/public/server/routes/redirects.json.get.ts +58 -0
- package/layers/public/server/routes/robots.txt.get.ts +1 -0
- package/layers/public/server/utils/llms-full.ts +125 -0
- package/layers/public/server/utils/llms.ts +13 -0
- package/layers/public/server/utils/page-resolve.ts +112 -4
- package/layers/public/server/utils/publish/invalidation.ts +48 -3
- package/layers/public/server/utils/publish/publisher.ts +11 -5
- package/layers/public/server/utils/publish/redirect-rules.ts +221 -0
- package/layers/public/server/utils/publish/redirects-artifact.ts +20 -0
- package/layers/public/server/utils/richtext-markdown.ts +260 -0
- package/layers/public/server/utils/site-url.ts +8 -0
- package/layers/public/server/utils/sitemap.ts +5 -3
- package/layers/ui/app/components/field/Choice.vue +6 -1
- package/layers/ui/app/components/field/DeadRefNote.vue +1 -1
- package/layers/ui/app/components/field/Slug.vue +2 -2
- package/layers/ui/app/components/ui/Button.vue +1 -1
- package/layers/ui/app/components/ui/EmptyState.vue +1 -1
- package/layers/ui/app/components/ui/TextInput.vue +1 -1
- package/layers/ui/app/components/ui/Toasts.vue +1 -1
- package/layers/ui/app/composables/useToast.ts +1 -1
- package/layers/ui/app/i18n/de.ts +13 -0
- package/layers/ui/app/i18n/en.ts +13 -0
- package/layers/ui/nuxt.config.ts +5 -1
- package/package.json +2 -1
- package/templates/starter/nuxt.config.ts +3 -0
|
@@ -3,7 +3,7 @@ import type { FieldDef } from '../../../core/server/utils/defineCollection'
|
|
|
3
3
|
import type { LayoutNode } from '../../../core/server/utils/field-layout'
|
|
4
4
|
import type { SeoMeta } from '../../../core/server/utils/seo'
|
|
5
5
|
|
|
6
|
-
// The collection (page) field list — delegates to the shared <
|
|
6
|
+
// The collection (page) field list — delegates to the shared <KestrelFieldLayout> renderer (rows / groups /
|
|
7
7
|
// widths, or one field per row when the collection declares no layout), shared by the flat-collection
|
|
8
8
|
// form and the 3-pane editor's root-selected pane so the wiring lives in exactly one place. pageLike
|
|
9
9
|
// collections also get their routable `path` (slug); seo-enabled ones get the SEO section — both as
|
|
@@ -30,6 +30,10 @@ const { t } = useT()
|
|
|
30
30
|
const layoutOptions = computed(() => layoutSelectOptions(useOfferableLayouts(), t('pageSettings.layoutDefault')))
|
|
31
31
|
const showLayout = computed(() => !!props.pageLike && layoutOptions.value.length > 1)
|
|
32
32
|
|
|
33
|
+
// Article metadata is an installation-wide policy (`kestrel.seo.articleMeta`), not a per-collection one,
|
|
34
|
+
// so it comes from runtimeConfig rather than the serialized collection schema.
|
|
35
|
+
const articleMeta = computed(() => (useRuntimeConfig().public as { seoArticleMeta?: boolean }).seoArticleMeta === true)
|
|
36
|
+
|
|
33
37
|
// Live preview of the slug the server will auto-generate from the title while the field is left blank
|
|
34
38
|
// (the server slugifies the title on save). Falls back to '/' when there is no title yet.
|
|
35
39
|
const slugPlaceholder = computed(() => {
|
|
@@ -41,14 +45,14 @@ const slugPlaceholder = computed(() => {
|
|
|
41
45
|
<template>
|
|
42
46
|
<!-- Publish gate (the `status` system column). The primary page-level control, so it leads the pane:
|
|
43
47
|
Draft keeps the page out of the generated site; Published renders + auto-publishes it. -->
|
|
44
|
-
<
|
|
48
|
+
<KestrelUiField
|
|
45
49
|
v-if="status"
|
|
46
50
|
class="page-settings__status"
|
|
47
51
|
:label="t('pageSettings.statusLabel')"
|
|
48
52
|
:hint="t('pageSettings.statusHint')"
|
|
49
53
|
>
|
|
50
54
|
<template #default="f">
|
|
51
|
-
<
|
|
55
|
+
<KestrelUiSelect
|
|
52
56
|
:model-value="(values.status as string) ?? 'draft'"
|
|
53
57
|
:options="[
|
|
54
58
|
{ label: t('pageSettings.statusDraft'), value: 'draft' },
|
|
@@ -59,11 +63,11 @@ const slugPlaceholder = computed(() => {
|
|
|
59
63
|
@update:model-value="(v) => emit('update', 'status', v)"
|
|
60
64
|
/>
|
|
61
65
|
</template>
|
|
62
|
-
</
|
|
66
|
+
</KestrelUiField>
|
|
63
67
|
|
|
64
68
|
<!-- The collection (page) fields, laid out by the shared renderer (rows / groups / widths, or one field
|
|
65
69
|
per row when the collection declares no `fieldLayout`). The condition gate + dead-ref note live inside. -->
|
|
66
|
-
<
|
|
70
|
+
<KestrelFieldLayout
|
|
67
71
|
:layout="fieldLayout"
|
|
68
72
|
:fields="fields"
|
|
69
73
|
:values="values"
|
|
@@ -75,7 +79,7 @@ const slugPlaceholder = computed(() => {
|
|
|
75
79
|
/>
|
|
76
80
|
|
|
77
81
|
<!-- Page slug (the routable path). A system column on pageLike collections, edited like a field. -->
|
|
78
|
-
<
|
|
82
|
+
<KestrelUiField
|
|
79
83
|
v-if="pageLike"
|
|
80
84
|
class="page-settings__slug"
|
|
81
85
|
:label="t('pageSettings.slugLabel')"
|
|
@@ -83,7 +87,7 @@ const slugPlaceholder = computed(() => {
|
|
|
83
87
|
:error="errors.path || null"
|
|
84
88
|
>
|
|
85
89
|
<template #default="f">
|
|
86
|
-
<
|
|
90
|
+
<KestrelUiTextInput
|
|
87
91
|
:model-value="(values.path as string) ?? ''"
|
|
88
92
|
:disabled="disabled"
|
|
89
93
|
:placeholder="slugPlaceholder"
|
|
@@ -91,11 +95,11 @@ const slugPlaceholder = computed(() => {
|
|
|
91
95
|
@update:model-value="(v) => emit('update', 'path', v)"
|
|
92
96
|
/>
|
|
93
97
|
</template>
|
|
94
|
-
</
|
|
98
|
+
</KestrelUiField>
|
|
95
99
|
|
|
96
100
|
<!-- Which layout renders this page (the `layout` system column). Empty = the `default` layout, so an
|
|
97
101
|
unset value keeps rendering exactly as a project without the column. -->
|
|
98
|
-
<
|
|
102
|
+
<KestrelUiField
|
|
99
103
|
v-if="showLayout"
|
|
100
104
|
class="page-settings__layout"
|
|
101
105
|
:label="t('pageSettings.layoutLabel')"
|
|
@@ -103,7 +107,7 @@ const slugPlaceholder = computed(() => {
|
|
|
103
107
|
:error="errors.layout || null"
|
|
104
108
|
>
|
|
105
109
|
<template #default="f">
|
|
106
|
-
<
|
|
110
|
+
<KestrelUiSelect
|
|
107
111
|
:model-value="(values.layout as string) ?? ''"
|
|
108
112
|
:options="layoutOptions"
|
|
109
113
|
:disabled="disabled"
|
|
@@ -111,16 +115,17 @@ const slugPlaceholder = computed(() => {
|
|
|
111
115
|
@update:model-value="(v) => emit('update', 'layout', v)"
|
|
112
116
|
/>
|
|
113
117
|
</template>
|
|
114
|
-
</
|
|
118
|
+
</KestrelUiField>
|
|
115
119
|
|
|
116
120
|
<!-- Page SEO (meta title/description/noindex + Google preview). The `seo` JSON system column. -->
|
|
117
|
-
<
|
|
121
|
+
<KestrelSeoFields
|
|
118
122
|
v-if="seo"
|
|
119
123
|
:value="(values.seo as SeoMeta) ?? {}"
|
|
120
124
|
:page-title="(values.title as string) ?? ''"
|
|
121
125
|
:path="(values.path as string) ?? ''"
|
|
122
126
|
:locale="locale"
|
|
123
127
|
:disabled="disabled"
|
|
128
|
+
:article-meta="articleMeta"
|
|
124
129
|
@update="(v) => emit('update', 'seo', v)"
|
|
125
130
|
/>
|
|
126
131
|
</template>
|
|
@@ -13,7 +13,7 @@ const { t } = useT()
|
|
|
13
13
|
<template>
|
|
14
14
|
<div v-if="translatable" class="ui-field">
|
|
15
15
|
<span class="ui-field__label">{{ t('localeBar.fieldLabel') }}</span>
|
|
16
|
-
<
|
|
16
|
+
<KestrelLocaleBar
|
|
17
17
|
:collection="collection"
|
|
18
18
|
:id="id"
|
|
19
19
|
:mode="mode"
|
|
@@ -23,7 +23,7 @@ const { t } = useT()
|
|
|
23
23
|
@copy-from="(loc) => emit('copyFrom', loc)"
|
|
24
24
|
/>
|
|
25
25
|
</div>
|
|
26
|
-
<
|
|
26
|
+
<KestrelPageFields
|
|
27
27
|
:fields="fields"
|
|
28
28
|
:field-layout="fieldLayout"
|
|
29
29
|
:values="values"
|
|
@@ -12,6 +12,10 @@ const props = defineProps<{
|
|
|
12
12
|
path?: string
|
|
13
13
|
locale: string
|
|
14
14
|
disabled?: boolean
|
|
15
|
+
/** `kestrel.seo.articleMeta`. Off (the default) hides author/date/keywords entirely: an installation
|
|
16
|
+
* that must not attribute content should never be offered the fields, not merely stop publishing
|
|
17
|
+
* them. Stored values are hidden, never cleared — turning the flag back on restores them. */
|
|
18
|
+
articleMeta?: boolean
|
|
15
19
|
}>()
|
|
16
20
|
const emit = defineEmits<{ update: [seo: SeoMeta] }>()
|
|
17
21
|
const { t } = useT()
|
|
@@ -52,9 +56,9 @@ const socialImageField = { type: 'media', options: { accept: 'image' } } as Fiel
|
|
|
52
56
|
<div class="seo-preview__desc">{{ previewDesc }}</div>
|
|
53
57
|
</div>
|
|
54
58
|
|
|
55
|
-
<
|
|
59
|
+
<KestrelUiField class="seo-fields__title" :label="t('seo.metaTitle')" :hint="`${titleLen}/${TITLE_MAX}`">
|
|
56
60
|
<template #default="f">
|
|
57
|
-
<
|
|
61
|
+
<KestrelUiTextInput
|
|
58
62
|
:model-value="value.title ?? ''"
|
|
59
63
|
:placeholder="pageTitle"
|
|
60
64
|
:disabled="disabled"
|
|
@@ -62,11 +66,11 @@ const socialImageField = { type: 'media', options: { accept: 'image' } } as Fiel
|
|
|
62
66
|
@update:model-value="(v) => patch({ title: v ?? '' })"
|
|
63
67
|
/>
|
|
64
68
|
</template>
|
|
65
|
-
</
|
|
69
|
+
</KestrelUiField>
|
|
66
70
|
|
|
67
|
-
<
|
|
71
|
+
<KestrelUiField class="seo-fields__desc" :label="t('seo.metaDescription')" :hint="`${descLen}/${DESC_MAX}`">
|
|
68
72
|
<template #default="f">
|
|
69
|
-
<
|
|
73
|
+
<KestrelUiTextarea
|
|
70
74
|
:model-value="value.description ?? ''"
|
|
71
75
|
:rows="3"
|
|
72
76
|
:disabled="disabled"
|
|
@@ -74,11 +78,11 @@ const socialImageField = { type: 'media', options: { accept: 'image' } } as Fiel
|
|
|
74
78
|
@update:model-value="(v) => patch({ description: v ?? '' })"
|
|
75
79
|
/>
|
|
76
80
|
</template>
|
|
77
|
-
</
|
|
81
|
+
</KestrelUiField>
|
|
78
82
|
|
|
79
|
-
<
|
|
83
|
+
<KestrelUiField class="seo-fields__image" :label="t('seo.socialImage')" :hint="t('seo.socialImageHint')">
|
|
80
84
|
<template #default="f">
|
|
81
|
-
<
|
|
85
|
+
<KestrelFieldMedia
|
|
82
86
|
:field="socialImageField"
|
|
83
87
|
name="seoImage"
|
|
84
88
|
:locale="locale"
|
|
@@ -88,11 +92,49 @@ const socialImageField = { type: 'media', options: { accept: 'image' } } as Fiel
|
|
|
88
92
|
@update:model-value="(v) => patch({ image: typeof v === 'number' ? v : null })"
|
|
89
93
|
/>
|
|
90
94
|
</template>
|
|
91
|
-
</
|
|
95
|
+
</KestrelUiField>
|
|
96
|
+
|
|
97
|
+
<!-- Article metadata (schema.org author / datePublished / keywords). Opt-in per installation. -->
|
|
98
|
+
<template v-if="articleMeta">
|
|
99
|
+
<KestrelUiField class="seo-fields__author" :label="t('seo.author')" :hint="t('seo.authorHint')">
|
|
100
|
+
<template #default="f">
|
|
101
|
+
<KestrelUiTextInput
|
|
102
|
+
:model-value="value.author ?? ''"
|
|
103
|
+
:disabled="disabled"
|
|
104
|
+
v-bind="f"
|
|
105
|
+
@update:model-value="(v) => patch({ author: v ?? '' })"
|
|
106
|
+
/>
|
|
107
|
+
</template>
|
|
108
|
+
</KestrelUiField>
|
|
109
|
+
|
|
110
|
+
<KestrelUiField class="seo-fields__published" :label="t('seo.publishedDate')" :hint="t('seo.publishedDateHint')">
|
|
111
|
+
<template #default="f">
|
|
112
|
+
<KestrelUiDatePicker
|
|
113
|
+
:model-value="value.publishedDate || null"
|
|
114
|
+
precision="date"
|
|
115
|
+
:disabled="disabled"
|
|
116
|
+
:aria-label="t('seo.publishedDate')"
|
|
117
|
+
:describedby="f['aria-describedby']"
|
|
118
|
+
@update:model-value="(v) => patch({ publishedDate: v ?? '' })"
|
|
119
|
+
/>
|
|
120
|
+
</template>
|
|
121
|
+
</KestrelUiField>
|
|
122
|
+
|
|
123
|
+
<KestrelUiField class="seo-fields__keywords" :label="t('seo.keywords')" :hint="t('seo.keywordsHint')">
|
|
124
|
+
<template #default="f">
|
|
125
|
+
<KestrelUiTextInput
|
|
126
|
+
:model-value="value.keywords ?? ''"
|
|
127
|
+
:disabled="disabled"
|
|
128
|
+
v-bind="f"
|
|
129
|
+
@update:model-value="(v) => patch({ keywords: v ?? '' })"
|
|
130
|
+
/>
|
|
131
|
+
</template>
|
|
132
|
+
</KestrelUiField>
|
|
133
|
+
</template>
|
|
92
134
|
|
|
93
135
|
<!-- 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 -->
|
|
94
136
|
<label class="seo-fields__noindex">
|
|
95
|
-
<
|
|
137
|
+
<KestrelUiCheckbox
|
|
96
138
|
:model-value="!!value.noindex"
|
|
97
139
|
:disabled="disabled"
|
|
98
140
|
@update:model-value="(v) => patch({ noindex: v })"
|
|
@@ -31,16 +31,16 @@ useUnsavedGuard(() => editorRef.value?.dirty ?? false, () => t('editor.discardCo
|
|
|
31
31
|
<div class="singleton__head">
|
|
32
32
|
<h1 class="singleton__title">{{ title }}</h1>
|
|
33
33
|
<div class="singleton__actions">
|
|
34
|
-
<
|
|
35
|
-
<
|
|
36
|
-
<
|
|
37
|
-
<
|
|
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
|
+
<KestrelUiButton type="submit" :form="EDITOR_FORM_ID" variant="primary" size="sm" icon="check" :loading="saving">{{ t('common.save') }}</KestrelUiButton>
|
|
38
38
|
<!-- Publishing is its own decision: Save persists, Publish writes the static page (ADR-0008). -->
|
|
39
|
-
<
|
|
40
|
-
<
|
|
39
|
+
<KestrelUiButton v-if="editorRef?.canPublish !== false" type="button" variant="secondary" size="sm" icon="upload" :loading="editorRef?.publishing" :disabled="saving" @click="editorRef?.publish()">{{ t('common.publish') }}</KestrelUiButton>
|
|
40
|
+
<KestrelEditorStatus class="singleton__ampel" :dirty="editorRef?.dirty ?? false" :saving="saving" :has-status="editorRef?.hasStatus ?? false" :status="editorRef?.status" :saved-status="editorRef?.savedStatus" :page-like="editorRef?.pageLike ?? false" :live="editorRef?.live" />
|
|
41
41
|
</div>
|
|
42
42
|
</div>
|
|
43
|
-
<
|
|
43
|
+
<KestrelCollectionEditor
|
|
44
44
|
ref="editorRef"
|
|
45
45
|
:collection="collection"
|
|
46
46
|
id="single"
|
|
@@ -278,7 +278,11 @@ export function useEditForm(opts: UseEditFormOptions) {
|
|
|
278
278
|
}
|
|
279
279
|
|
|
280
280
|
function handleError(e: unknown) {
|
|
281
|
-
const { statusCode, statusMessage, issues } = readFetchError(e)
|
|
281
|
+
const { statusCode, statusMessage, issues, data } = readFetchError(e)
|
|
282
|
+
// The row WAS written and a follow-up step failed (a write effect — see write-effects.ts), so the
|
|
283
|
+
// server hands back the new `updatedAt`. Take it as the baseline: the client only rebaselines on
|
|
284
|
+
// success, and without this the "save again" those errors ask for would 409 on a stale precondition.
|
|
285
|
+
if (typeof data?.savedUpdatedAt === 'number') baseUpdatedAt.value = data.savedUpdatedAt
|
|
282
286
|
if (statusCode === 400) {
|
|
283
287
|
const mapped = mapServerErrors(issues)
|
|
284
288
|
for (const [k, msg] of Object.entries(mapped.fields)) errors[k] = msg
|
|
@@ -292,9 +296,11 @@ export function useEditForm(opts: UseEditFormOptions) {
|
|
|
292
296
|
? t('editor.fixBlockContent')
|
|
293
297
|
: Object.keys(mapped.fields).length ? t('editor.fixPageFields') : '')
|
|
294
298
|
} else if (statusCode === 409) {
|
|
295
|
-
|
|
299
|
+
// `||`, not `??`: an absent reason phrase arrives as '' rather than undefined, and an empty banner
|
|
300
|
+
// is a silent failure — the Save button simply stops spinning.
|
|
301
|
+
formError.value = statusMessage || t('editor.saveConflict')
|
|
296
302
|
} else {
|
|
297
|
-
formError.value = statusMessage
|
|
303
|
+
formError.value = statusMessage || t('editor.saveFailed')
|
|
298
304
|
}
|
|
299
305
|
}
|
|
300
306
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Row and bulk actions for the collection list: a row action is one id, the bulk bar is N — the server
|
|
2
|
+
// does the batching, so both go through the same calls.
|
|
3
|
+
//
|
|
4
|
+
// Destructive → confirm dialog. Reversible → run, toast, refetch (the ops composable's onChanged).
|
|
5
|
+
import { ref, type ComputedRef } from 'vue'
|
|
6
|
+
import type { BatchDeleteReport } from '../utils/collection-ops'
|
|
7
|
+
|
|
8
|
+
export function useListBatchActions(collection: ComputedRef<string>, refetch: () => void | Promise<void>) {
|
|
9
|
+
const { t } = useT()
|
|
10
|
+
const toast = useToast()
|
|
11
|
+
const { busy, error, previewDelete, confirmDelete: runDelete, duplicate: runDuplicate, setStatus: runSetStatus } =
|
|
12
|
+
useCollectionOps(collection, () => refetch())
|
|
13
|
+
|
|
14
|
+
const deleteOpen = ref(false)
|
|
15
|
+
const deleteReport = ref<BatchDeleteReport | null>(null)
|
|
16
|
+
const deleteIds = ref<number[]>([])
|
|
17
|
+
|
|
18
|
+
// The referrer preview is best-effort: if it fails we still open the dialog with a bare summary (never
|
|
19
|
+
// block a delete on the warning lookup).
|
|
20
|
+
async function askDelete(ids: number[]) {
|
|
21
|
+
if (!ids.length) return
|
|
22
|
+
deleteIds.value = ids
|
|
23
|
+
deleteReport.value = null
|
|
24
|
+
deleteOpen.value = true
|
|
25
|
+
try {
|
|
26
|
+
deleteReport.value = await previewDelete(ids)
|
|
27
|
+
} catch {
|
|
28
|
+
// The referrer lookup FAILED — do not fabricate a "no inbound links" report (that would look exactly
|
|
29
|
+
// like a verified-safe delete). Flag `checked: false` so the dialog cautions the check couldn't run.
|
|
30
|
+
deleteReport.value = { count: ids.length, referencedCount: 0, referenced: [], checked: false }
|
|
31
|
+
error.value = null
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function confirmDelete() {
|
|
35
|
+
try {
|
|
36
|
+
await runDelete(deleteIds.value)
|
|
37
|
+
toast.success(t('toast.deleted'))
|
|
38
|
+
deleteOpen.value = false
|
|
39
|
+
} catch {
|
|
40
|
+
// error stays surfaced in the dialog via `error`
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async function duplicate(id: number) {
|
|
44
|
+
try {
|
|
45
|
+
await runDuplicate([id])
|
|
46
|
+
toast.success(t('toast.duplicated'))
|
|
47
|
+
} catch {
|
|
48
|
+
toast.error(error.value ?? t('list.opFailed'))
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function setStatus(ids: number[], status: 'published' | 'draft') {
|
|
52
|
+
try {
|
|
53
|
+
await runSetStatus(ids, status)
|
|
54
|
+
toast.success(status === 'published' ? t('toast.published') : t('toast.unpublished'))
|
|
55
|
+
} catch {
|
|
56
|
+
toast.error(error.value ?? t('list.opFailed'))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { busy, error, deleteOpen, deleteReport, askDelete, confirmDelete, duplicate, setStatus }
|
|
61
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
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, type FilterKind, type FilterOp } from '../../../core/app/utils/filter-ops'
|
|
7
|
+
import { resolveLocalized } from '../../../ui/app/utils/localized'
|
|
8
|
+
import { columnLabel } from '../utils/list-cell'
|
|
9
|
+
import type { Localized } from '../../../core/server/utils/defineCollection'
|
|
10
|
+
import type { ListColumn } from '../utils/list-columns'
|
|
11
|
+
import type { FilterCell } from '../utils/list-query'
|
|
12
|
+
|
|
13
|
+
const COMMIT_DEBOUNCE_MS = 250
|
|
14
|
+
|
|
15
|
+
/** One active clause as the chip row renders it. */
|
|
16
|
+
export interface ActiveFilter {
|
|
17
|
+
key: string
|
|
18
|
+
op: FilterOp
|
|
19
|
+
opLabel: string
|
|
20
|
+
label: string
|
|
21
|
+
display: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The panel's whole controller — passed to the filter panel as one prop rather than a dozen. */
|
|
25
|
+
export interface ListFilterDraft {
|
|
26
|
+
draft: Record<string, FilterCell>
|
|
27
|
+
activeFilters: ComputedRef<ActiveFilter[]>
|
|
28
|
+
opsFor: (c: ListColumn) => readonly FilterOp[]
|
|
29
|
+
opLabel: (kind: FilterKind, op: FilterOp) => string
|
|
30
|
+
enumOptions: (c: ListColumn) => { label: string; value: string }[]
|
|
31
|
+
setValue: (field: string, value: string) => void
|
|
32
|
+
setOp: (field: string, op: FilterOp) => void
|
|
33
|
+
clearFilter: (field: string) => void
|
|
34
|
+
clearAll: () => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface FilterDraftOptions {
|
|
38
|
+
available: ComputedRef<ListColumn[]>
|
|
39
|
+
filterableColumns: ComputedRef<ListColumn[]>
|
|
40
|
+
filter: ComputedRef<Record<string, FilterCell>>
|
|
41
|
+
setFilter: (next: Record<string, FilterCell>) => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function useListFilterDraft(opts: FilterDraftOptions): ListFilterDraft {
|
|
45
|
+
const { t, lang } = useT()
|
|
46
|
+
const draft = reactive<Record<string, FilterCell>>({})
|
|
47
|
+
|
|
48
|
+
// Overwrite each draft cell FROM the committed `filter` so deep-link / Back-Forward / chip-removal reflect
|
|
49
|
+
// back into the open panel; drop drafts for columns this collection no longer has. We key the watch on the
|
|
50
|
+
// committed filter's CONTENT (its JSON), not its object identity: `filter` recomputes to a fresh-but-EQUAL
|
|
51
|
+
// object on every `route.query` change (page/sort/perPage navigations too), and resyncing on those identity
|
|
52
|
+
// flips would wipe an in-progress panel edit. Clobber-safe on a real filter commit: a commit sends ALL
|
|
53
|
+
// drafts at once, so post-commit `filter` equals `draft` (a no-op overwrite) — the content only actually
|
|
54
|
+
// changes on external navigation (deep-link/Back-Forward/chip-removal), which is when the panel should resync.
|
|
55
|
+
watch([opts.filterableColumns, () => JSON.stringify(opts.filter.value)], () => {
|
|
56
|
+
const cols = opts.filterableColumns.value
|
|
57
|
+
for (const c of cols) {
|
|
58
|
+
const cm = opts.filter.value[c.key]
|
|
59
|
+
draft[c.key] = { op: cm?.op ?? DEFAULT_OP[c.filterKind ?? 'text'], value: cm?.value ?? '' }
|
|
60
|
+
}
|
|
61
|
+
for (const key of Object.keys(draft)) if (!cols.some((c) => c.key === key)) Reflect.deleteProperty(draft, key)
|
|
62
|
+
}, { immediate: true })
|
|
63
|
+
|
|
64
|
+
function opsFor(c: ListColumn): readonly FilterOp[] {
|
|
65
|
+
return OPS_BY_KIND[c.filterKind ?? 'text']
|
|
66
|
+
}
|
|
67
|
+
// Operator labels are kind-aware: a datetime comparison reads "before / on or before / …" instead of the
|
|
68
|
+
// generic "less than / less or equal / …".
|
|
69
|
+
function opLabel(kind: FilterKind, op: FilterOp): string {
|
|
70
|
+
if (kind === 'datetime' && (op === 'lt' || op === 'lte' || op === 'gt' || op === 'gte')) return t(`filter.opDate.${op}`)
|
|
71
|
+
return t(`filter.op.${op}`)
|
|
72
|
+
}
|
|
73
|
+
// Choices for an `enum` / `stringSet` value control: a fixed draft/published set for `status`, else the
|
|
74
|
+
// choice field's own (single- and multi-choice fields both carry `options.choices`).
|
|
75
|
+
function enumOptions(c: ListColumn): { label: string; value: string }[] {
|
|
76
|
+
if (c.key === 'status') return [
|
|
77
|
+
{ value: 'draft', label: t('pageSettings.statusDraft') },
|
|
78
|
+
{ value: 'published', label: t('pageSettings.statusPublished') },
|
|
79
|
+
]
|
|
80
|
+
// A choice label is `Localized`, so resolve it here as the editor widget does — `{{ o.label }}` would
|
|
81
|
+
// stringify a `{ en, de }` map into the filter dropdown.
|
|
82
|
+
return ((c.field?.options?.choices ?? []) as { label: Localized; value: string }[])
|
|
83
|
+
.map((o) => ({ value: o.value, label: resolveLocalized(o.label, lang.value) ?? o.value }))
|
|
84
|
+
}
|
|
85
|
+
function displayValue(kind: FilterKind, value: string): string {
|
|
86
|
+
if (kind === 'boolean') return value === 'true' ? t('filter.bool.true') : value === 'false' ? t('filter.bool.false') : value
|
|
87
|
+
return value
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Builds a fresh clause map from the non-empty draft cells and navigates via `setFilter` (which also
|
|
91
|
+
// resets page → 1).
|
|
92
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
93
|
+
function commitDrafts() {
|
|
94
|
+
const next: Record<string, FilterCell> = {}
|
|
95
|
+
for (const [field, cell] of Object.entries(draft)) {
|
|
96
|
+
if (cell.value !== '' && cell.value != null) next[field] = { op: cell.op, value: cell.value }
|
|
97
|
+
}
|
|
98
|
+
opts.setFilter(next)
|
|
99
|
+
}
|
|
100
|
+
function setValue(field: string, value: string) {
|
|
101
|
+
if (draft[field]) draft[field].value = value
|
|
102
|
+
clearTimeout(timer)
|
|
103
|
+
timer = setTimeout(commitDrafts, COMMIT_DEBOUNCE_MS)
|
|
104
|
+
}
|
|
105
|
+
function setOp(field: string, op: FilterOp) {
|
|
106
|
+
if (draft[field]) draft[field].op = op
|
|
107
|
+
// An operator change is deliberate — re-query immediately (no debounce) if the field has a value.
|
|
108
|
+
clearTimeout(timer)
|
|
109
|
+
commitDrafts()
|
|
110
|
+
}
|
|
111
|
+
function clearFilter(field: string) {
|
|
112
|
+
if (draft[field]) draft[field].value = ''
|
|
113
|
+
commitDrafts() // re-commits the drafts with this one now empty → the clause is dropped (and page → 1)
|
|
114
|
+
}
|
|
115
|
+
function clearAll() {
|
|
116
|
+
for (const cell of Object.values(draft)) cell.value = ''
|
|
117
|
+
opts.setFilter({}) // clear the committed clauses (and page → 1); the draft-resync watch clears the panel
|
|
118
|
+
}
|
|
119
|
+
onBeforeUnmount(() => clearTimeout(timer))
|
|
120
|
+
|
|
121
|
+
const activeFilters = computed(() => Object.entries(opts.filter.value).map(([key, cell]) => {
|
|
122
|
+
const col = opts.available.value.find((c) => c.key === key)
|
|
123
|
+
const kind = col?.filterKind ?? 'text'
|
|
124
|
+
return { key, op: cell.op, opLabel: opLabel(kind, cell.op), label: col ? columnLabel(col, t) : key, display: displayValue(kind, cell.value) }
|
|
125
|
+
}))
|
|
126
|
+
|
|
127
|
+
return { draft, activeFilters, opsFor, opLabel, enumOptions, setValue, setOp, clearFilter, clearAll }
|
|
128
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
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
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useListRows(opts: ListRowsOptions) {
|
|
26
|
+
const { t } = useT()
|
|
27
|
+
const rows = ref<Record<string, unknown>[]>([])
|
|
28
|
+
const total = ref(0)
|
|
29
|
+
// Surfaced when a (re)fetch fails — an inline error + Retry, never a silent stale list or a thrown
|
|
30
|
+
// error boundary. Distinct from the empty state (which only shows when the fetch SUCCEEDED with no rows).
|
|
31
|
+
const error = ref<string | null>(null)
|
|
32
|
+
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / opts.perPage.value)))
|
|
33
|
+
|
|
34
|
+
// Monotonic request id: a slow earlier response must not overwrite a newer one.
|
|
35
|
+
let seq = 0
|
|
36
|
+
async function fetchRows() {
|
|
37
|
+
const mine = ++seq
|
|
38
|
+
const locale = opts.locale()
|
|
39
|
+
try {
|
|
40
|
+
const res = await $fetch<ListResponse>(`/api/${opts.collection.value}`, {
|
|
41
|
+
query: { ...opts.effectiveQuery.value, ...(locale ? { locale } : {}) },
|
|
42
|
+
})
|
|
43
|
+
if (mine !== seq) return
|
|
44
|
+
rows.value = res.data
|
|
45
|
+
total.value = res.total
|
|
46
|
+
error.value = null
|
|
47
|
+
// A page that emptied out from under us — e.g. bulk-deleting the last rows of the trailing page —
|
|
48
|
+
// would otherwise strand the user on an out-of-range page showing the "create your first" empty
|
|
49
|
+
// state. Clamp back to the last page that still has rows (REPLACE, so the dead page isn't left as a
|
|
50
|
+
// history entry that Back would bounce off; the query watch refetches).
|
|
51
|
+
if (res.data.length === 0 && res.total > 0 && opts.page.value > 1) {
|
|
52
|
+
opts.clampPage(Math.max(1, Math.ceil(res.total / opts.perPage.value)))
|
|
53
|
+
}
|
|
54
|
+
opts.onLoaded?.()
|
|
55
|
+
} catch (e) {
|
|
56
|
+
if (mine !== seq) return
|
|
57
|
+
// Keep any previously-rendered rows, but surface that this fetch failed (and don't show the
|
|
58
|
+
// "create your first" empty state — that would imply the collection is empty when it isn't).
|
|
59
|
+
error.value = (e as { statusMessage?: string })?.statusMessage ?? t('list.loadError')
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { rows, total, error, totalPages, fetchRows } as {
|
|
64
|
+
rows: Ref<Record<string, unknown>[]>
|
|
65
|
+
total: Ref<number>
|
|
66
|
+
error: Ref<string | null>
|
|
67
|
+
totalPages: ComputedRef<number>
|
|
68
|
+
fetchRows: () => Promise<void>
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
}
|