@michaelthielemann/kestrel 2.1.0 → 3.0.1
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 +2 -1
- 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 -792
- 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 +12 -12
- package/layers/admin/app/components/PageFieldsPane.vue +2 -2
- package/layers/admin/app/components/SeoFields.vue +19 -19
- package/layers/admin/app/components/SingletonEditor.vue +7 -7
- 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/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/nuxt.config.ts +1 -0
- package/layers/core/server/utils/defineCollection.ts +0 -1
- package/layers/core/server/utils/serialize-collection.ts +0 -2
- package/layers/fields/app/utils/field-factories.ts +1 -1
- 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 +20 -20
- 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 +14 -14
- package/layers/media/app/components/field/Media.vue +9 -9
- package/layers/media/app/plugins/field-media.client.ts +3 -3
- package/layers/media/nuxt.config.ts +5 -1
- package/layers/public/app/pages/[...slug].vue +2 -2
- package/layers/public/app/pages/__kestrel/preview.vue +1 -1
- package/layers/public/nuxt.config.ts +3 -0
- package/layers/ui/app/components/field/DeadRefNote.vue +1 -1
- package/layers/ui/app/components/field/Repeater.vue +5 -5
- package/layers/ui/app/components/field/Slug.vue +2 -2
- package/layers/ui/app/components/ui/Button.vue +3 -3
- package/layers/ui/app/components/ui/ButtonGroup.vue +2 -2
- package/layers/ui/app/components/ui/Checkbox.vue +2 -2
- package/layers/ui/app/components/ui/Combobox.vue +5 -5
- package/layers/ui/app/components/ui/DatePicker.vue +2 -2
- package/layers/ui/app/components/ui/DateRangePicker.vue +2 -2
- package/layers/ui/app/components/ui/EmptyState.vue +1 -1
- package/layers/ui/app/components/ui/NumberInput.vue +4 -4
- package/layers/ui/app/components/ui/Select.vue +2 -2
- package/layers/ui/app/components/ui/TextInput.vue +5 -5
- package/layers/ui/app/components/ui/Textarea.vue +2 -2
- package/layers/ui/app/components/ui/TimeInput.vue +2 -2
- package/layers/ui/app/components/ui/Toasts.vue +4 -4
- package/layers/ui/app/composables/useToast.ts +1 -1
- package/layers/ui/nuxt.config.ts +5 -1
- package/package.json +1 -1
|
@@ -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
|
|
@@ -45,14 +45,14 @@ const slugPlaceholder = computed(() => {
|
|
|
45
45
|
<template>
|
|
46
46
|
<!-- Publish gate (the `status` system column). The primary page-level control, so it leads the pane:
|
|
47
47
|
Draft keeps the page out of the generated site; Published renders + auto-publishes it. -->
|
|
48
|
-
<
|
|
48
|
+
<KestrelUiField
|
|
49
49
|
v-if="status"
|
|
50
50
|
class="page-settings__status"
|
|
51
51
|
:label="t('pageSettings.statusLabel')"
|
|
52
52
|
:hint="t('pageSettings.statusHint')"
|
|
53
53
|
>
|
|
54
54
|
<template #default="f">
|
|
55
|
-
<
|
|
55
|
+
<KestrelUiSelect
|
|
56
56
|
:model-value="(values.status as string) ?? 'draft'"
|
|
57
57
|
:options="[
|
|
58
58
|
{ label: t('pageSettings.statusDraft'), value: 'draft' },
|
|
@@ -63,11 +63,11 @@ const slugPlaceholder = computed(() => {
|
|
|
63
63
|
@update:model-value="(v) => emit('update', 'status', v)"
|
|
64
64
|
/>
|
|
65
65
|
</template>
|
|
66
|
-
</
|
|
66
|
+
</KestrelUiField>
|
|
67
67
|
|
|
68
68
|
<!-- The collection (page) fields, laid out by the shared renderer (rows / groups / widths, or one field
|
|
69
69
|
per row when the collection declares no `fieldLayout`). The condition gate + dead-ref note live inside. -->
|
|
70
|
-
<
|
|
70
|
+
<KestrelFieldLayout
|
|
71
71
|
:layout="fieldLayout"
|
|
72
72
|
:fields="fields"
|
|
73
73
|
:values="values"
|
|
@@ -79,7 +79,7 @@ const slugPlaceholder = computed(() => {
|
|
|
79
79
|
/>
|
|
80
80
|
|
|
81
81
|
<!-- Page slug (the routable path). A system column on pageLike collections, edited like a field. -->
|
|
82
|
-
<
|
|
82
|
+
<KestrelUiField
|
|
83
83
|
v-if="pageLike"
|
|
84
84
|
class="page-settings__slug"
|
|
85
85
|
:label="t('pageSettings.slugLabel')"
|
|
@@ -87,7 +87,7 @@ const slugPlaceholder = computed(() => {
|
|
|
87
87
|
:error="errors.path || null"
|
|
88
88
|
>
|
|
89
89
|
<template #default="f">
|
|
90
|
-
<
|
|
90
|
+
<KestrelUiTextInput
|
|
91
91
|
:model-value="(values.path as string) ?? ''"
|
|
92
92
|
:disabled="disabled"
|
|
93
93
|
:placeholder="slugPlaceholder"
|
|
@@ -95,11 +95,11 @@ const slugPlaceholder = computed(() => {
|
|
|
95
95
|
@update:model-value="(v) => emit('update', 'path', v)"
|
|
96
96
|
/>
|
|
97
97
|
</template>
|
|
98
|
-
</
|
|
98
|
+
</KestrelUiField>
|
|
99
99
|
|
|
100
100
|
<!-- Which layout renders this page (the `layout` system column). Empty = the `default` layout, so an
|
|
101
101
|
unset value keeps rendering exactly as a project without the column. -->
|
|
102
|
-
<
|
|
102
|
+
<KestrelUiField
|
|
103
103
|
v-if="showLayout"
|
|
104
104
|
class="page-settings__layout"
|
|
105
105
|
:label="t('pageSettings.layoutLabel')"
|
|
@@ -107,7 +107,7 @@ const slugPlaceholder = computed(() => {
|
|
|
107
107
|
:error="errors.layout || null"
|
|
108
108
|
>
|
|
109
109
|
<template #default="f">
|
|
110
|
-
<
|
|
110
|
+
<KestrelUiSelect
|
|
111
111
|
:model-value="(values.layout as string) ?? ''"
|
|
112
112
|
:options="layoutOptions"
|
|
113
113
|
:disabled="disabled"
|
|
@@ -115,10 +115,10 @@ const slugPlaceholder = computed(() => {
|
|
|
115
115
|
@update:model-value="(v) => emit('update', 'layout', v)"
|
|
116
116
|
/>
|
|
117
117
|
</template>
|
|
118
|
-
</
|
|
118
|
+
</KestrelUiField>
|
|
119
119
|
|
|
120
120
|
<!-- Page SEO (meta title/description/noindex + Google preview). The `seo` JSON system column. -->
|
|
121
|
-
<
|
|
121
|
+
<KestrelSeoFields
|
|
122
122
|
v-if="seo"
|
|
123
123
|
:value="(values.seo as SeoMeta) ?? {}"
|
|
124
124
|
:page-title="(values.title as string) ?? ''"
|
|
@@ -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"
|
|
@@ -56,9 +56,9 @@ const socialImageField = { type: 'media', options: { accept: 'image' } } as Fiel
|
|
|
56
56
|
<div class="seo-preview__desc">{{ previewDesc }}</div>
|
|
57
57
|
</div>
|
|
58
58
|
|
|
59
|
-
<
|
|
59
|
+
<KestrelUiField class="seo-fields__title" :label="t('seo.metaTitle')" :hint="`${titleLen}/${TITLE_MAX}`">
|
|
60
60
|
<template #default="f">
|
|
61
|
-
<
|
|
61
|
+
<KestrelUiTextInput
|
|
62
62
|
:model-value="value.title ?? ''"
|
|
63
63
|
:placeholder="pageTitle"
|
|
64
64
|
:disabled="disabled"
|
|
@@ -66,11 +66,11 @@ const socialImageField = { type: 'media', options: { accept: 'image' } } as Fiel
|
|
|
66
66
|
@update:model-value="(v) => patch({ title: v ?? '' })"
|
|
67
67
|
/>
|
|
68
68
|
</template>
|
|
69
|
-
</
|
|
69
|
+
</KestrelUiField>
|
|
70
70
|
|
|
71
|
-
<
|
|
71
|
+
<KestrelUiField class="seo-fields__desc" :label="t('seo.metaDescription')" :hint="`${descLen}/${DESC_MAX}`">
|
|
72
72
|
<template #default="f">
|
|
73
|
-
<
|
|
73
|
+
<KestrelUiTextarea
|
|
74
74
|
:model-value="value.description ?? ''"
|
|
75
75
|
:rows="3"
|
|
76
76
|
:disabled="disabled"
|
|
@@ -78,11 +78,11 @@ const socialImageField = { type: 'media', options: { accept: 'image' } } as Fiel
|
|
|
78
78
|
@update:model-value="(v) => patch({ description: v ?? '' })"
|
|
79
79
|
/>
|
|
80
80
|
</template>
|
|
81
|
-
</
|
|
81
|
+
</KestrelUiField>
|
|
82
82
|
|
|
83
|
-
<
|
|
83
|
+
<KestrelUiField class="seo-fields__image" :label="t('seo.socialImage')" :hint="t('seo.socialImageHint')">
|
|
84
84
|
<template #default="f">
|
|
85
|
-
<
|
|
85
|
+
<KestrelFieldMedia
|
|
86
86
|
:field="socialImageField"
|
|
87
87
|
name="seoImage"
|
|
88
88
|
:locale="locale"
|
|
@@ -92,24 +92,24 @@ const socialImageField = { type: 'media', options: { accept: 'image' } } as Fiel
|
|
|
92
92
|
@update:model-value="(v) => patch({ image: typeof v === 'number' ? v : null })"
|
|
93
93
|
/>
|
|
94
94
|
</template>
|
|
95
|
-
</
|
|
95
|
+
</KestrelUiField>
|
|
96
96
|
|
|
97
97
|
<!-- Article metadata (schema.org author / datePublished / keywords). Opt-in per installation. -->
|
|
98
98
|
<template v-if="articleMeta">
|
|
99
|
-
<
|
|
99
|
+
<KestrelUiField class="seo-fields__author" :label="t('seo.author')" :hint="t('seo.authorHint')">
|
|
100
100
|
<template #default="f">
|
|
101
|
-
<
|
|
101
|
+
<KestrelUiTextInput
|
|
102
102
|
:model-value="value.author ?? ''"
|
|
103
103
|
:disabled="disabled"
|
|
104
104
|
v-bind="f"
|
|
105
105
|
@update:model-value="(v) => patch({ author: v ?? '' })"
|
|
106
106
|
/>
|
|
107
107
|
</template>
|
|
108
|
-
</
|
|
108
|
+
</KestrelUiField>
|
|
109
109
|
|
|
110
|
-
<
|
|
110
|
+
<KestrelUiField class="seo-fields__published" :label="t('seo.publishedDate')" :hint="t('seo.publishedDateHint')">
|
|
111
111
|
<template #default="f">
|
|
112
|
-
<
|
|
112
|
+
<KestrelUiDatePicker
|
|
113
113
|
:model-value="value.publishedDate || null"
|
|
114
114
|
precision="date"
|
|
115
115
|
:disabled="disabled"
|
|
@@ -118,23 +118,23 @@ const socialImageField = { type: 'media', options: { accept: 'image' } } as Fiel
|
|
|
118
118
|
@update:model-value="(v) => patch({ publishedDate: v ?? '' })"
|
|
119
119
|
/>
|
|
120
120
|
</template>
|
|
121
|
-
</
|
|
121
|
+
</KestrelUiField>
|
|
122
122
|
|
|
123
|
-
<
|
|
123
|
+
<KestrelUiField class="seo-fields__keywords" :label="t('seo.keywords')" :hint="t('seo.keywordsHint')">
|
|
124
124
|
<template #default="f">
|
|
125
|
-
<
|
|
125
|
+
<KestrelUiTextInput
|
|
126
126
|
:model-value="value.keywords ?? ''"
|
|
127
127
|
:disabled="disabled"
|
|
128
128
|
v-bind="f"
|
|
129
129
|
@update:model-value="(v) => patch({ keywords: v ?? '' })"
|
|
130
130
|
/>
|
|
131
131
|
</template>
|
|
132
|
-
</
|
|
132
|
+
</KestrelUiField>
|
|
133
133
|
</template>
|
|
134
134
|
|
|
135
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 -->
|
|
136
136
|
<label class="seo-fields__noindex">
|
|
137
|
-
<
|
|
137
|
+
<KestrelUiCheckbox
|
|
138
138
|
:model-value="!!value.noindex"
|
|
139
139
|
:disabled="disabled"
|
|
140
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"
|
|
@@ -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
|
+
}
|
|
@@ -32,7 +32,7 @@ useHead(() => ({
|
|
|
32
32
|
<aside v-if="authenticated" class="admin__rail">
|
|
33
33
|
<div class="rail__head">
|
|
34
34
|
<NuxtLink to="/admin" class="rail__brand" aria-label="Kestrel">
|
|
35
|
-
<
|
|
35
|
+
<KestrelUiBrand />
|
|
36
36
|
<span class="rail__brand-word rail__label">kestrel</span>
|
|
37
37
|
</NuxtLink>
|
|
38
38
|
<button
|
|
@@ -41,7 +41,7 @@ useHead(() => ({
|
|
|
41
41
|
:aria-label="collapsed ? t('a11y.expandSidebar') : t('a11y.collapseSidebar')"
|
|
42
42
|
@click="toggleRail"
|
|
43
43
|
>
|
|
44
|
-
<
|
|
44
|
+
<KestrelUiIcon :name="collapsed ? 'panel-left-open' : 'panel-left-close'" size="1.25rem" />
|
|
45
45
|
</button>
|
|
46
46
|
</div>
|
|
47
47
|
|
|
@@ -51,15 +51,15 @@ useHead(() => ({
|
|
|
51
51
|
class="rail__item rail__item--dashboard"
|
|
52
52
|
:title="collapsed ? t('nav.dashboard') : undefined"
|
|
53
53
|
>
|
|
54
|
-
<
|
|
54
|
+
<KestrelUiIcon name="home" class="rail__icon" size="1.25rem" />
|
|
55
55
|
<span class="rail__label">{{ t('nav.dashboard') }}</span>
|
|
56
56
|
</NuxtLink>
|
|
57
57
|
|
|
58
|
-
<
|
|
58
|
+
<KestrelAdminNav />
|
|
59
59
|
</div>
|
|
60
60
|
|
|
61
61
|
<div class="rail__foot">
|
|
62
|
-
<
|
|
62
|
+
<KestrelAdminAccount />
|
|
63
63
|
<button
|
|
64
64
|
type="button"
|
|
65
65
|
class="rail__item"
|
|
@@ -67,14 +67,14 @@ useHead(() => ({
|
|
|
67
67
|
:aria-label="theme === 'dark' ? t('a11y.toLight') : t('a11y.toDark')"
|
|
68
68
|
@click="toggleTheme"
|
|
69
69
|
>
|
|
70
|
-
<
|
|
70
|
+
<KestrelUiIcon :name="theme === 'dark' ? 'sun' : 'moon'" class="rail__icon" size="1.25rem" />
|
|
71
71
|
<span class="rail__label">{{ theme === 'dark' ? t('theme.light') : t('theme.dark') }}</span>
|
|
72
72
|
</button>
|
|
73
73
|
</div>
|
|
74
74
|
</aside>
|
|
75
75
|
|
|
76
76
|
<main class="admin__main"><slot /></main>
|
|
77
|
-
<
|
|
77
|
+
<KestrelUiToasts />
|
|
78
78
|
</div>
|
|
79
79
|
</template>
|
|
80
80
|
|
|
@@ -8,13 +8,13 @@ import RecordPage from './[id].vue'
|
|
|
8
8
|
const thingsSchema = {
|
|
9
9
|
name: 'things', mode: 'multi', translatable: false, pageLike: false, seo: false, status: false,
|
|
10
10
|
blocks: { enabled: false }, label: { singular: 'Thing', plural: 'Things' },
|
|
11
|
-
fields: { title: { type: 'text', required: true,
|
|
11
|
+
fields: { title: { type: 'text', required: true, unique: false } },
|
|
12
12
|
}
|
|
13
13
|
// Carries an explicit `label.new` — the create heading must use it verbatim, not the generic template.
|
|
14
14
|
const articlesSchema = {
|
|
15
15
|
name: 'articles', mode: 'multi', translatable: false, pageLike: false, seo: false, status: false,
|
|
16
16
|
blocks: { enabled: false }, label: { singular: 'Article', plural: 'Articles', new: 'Compose article' },
|
|
17
|
-
fields: { title: { type: 'text', required: true,
|
|
17
|
+
fields: { title: { type: 'text', required: true, unique: false } },
|
|
18
18
|
}
|
|
19
19
|
registerEndpoint('/api/collections', () => ({ data: [thingsSchema, articlesSchema] }))
|
|
20
20
|
registerEndpoint('/api/articles', () => ({ data: [], total: 0, page: 1, perPage: 25 }))
|