@michaelthielemann/kestrel 2.1.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.
Files changed (71) hide show
  1. package/README.md +2 -1
  2. package/layers/admin/app/components/AdminAccount.vue +3 -3
  3. package/layers/admin/app/components/AdminNav.vue +1 -1
  4. package/layers/admin/app/components/BlockFields.vue +2 -2
  5. package/layers/admin/app/components/BlockPreview.vue +8 -8
  6. package/layers/admin/app/components/BlockTree.vue +10 -10
  7. package/layers/admin/app/components/BlocksBody.vue +4 -4
  8. package/layers/admin/app/components/CollectionDeleteDialog.vue +7 -7
  9. package/layers/admin/app/components/CollectionEditor.vue +3 -3
  10. package/layers/admin/app/components/CollectionList.vue +91 -792
  11. package/layers/admin/app/components/CollectionListBulkBar.vue +48 -0
  12. package/layers/admin/app/components/CollectionListColumnsPanel.vue +37 -0
  13. package/layers/admin/app/components/CollectionListFilterPanel.vue +145 -0
  14. package/layers/admin/app/components/CollectionListPager.vue +70 -0
  15. package/layers/admin/app/components/CollectionListTable.vue +308 -0
  16. package/layers/admin/app/components/EditorStatus.vue +4 -4
  17. package/layers/admin/app/components/FieldsBody.vue +1 -1
  18. package/layers/admin/app/components/LocaleBar.vue +4 -4
  19. package/layers/admin/app/components/PageFields.vue +12 -12
  20. package/layers/admin/app/components/PageFieldsPane.vue +2 -2
  21. package/layers/admin/app/components/SeoFields.vue +19 -19
  22. package/layers/admin/app/components/SingletonEditor.vue +7 -7
  23. package/layers/admin/app/composables/useListBatchActions.ts +61 -0
  24. package/layers/admin/app/composables/useListFilterDraft.ts +128 -0
  25. package/layers/admin/app/composables/useListRows.ts +70 -0
  26. package/layers/admin/app/composables/useListSelection.ts +28 -0
  27. package/layers/admin/app/composables/useToolbarPanel.ts +38 -0
  28. package/layers/admin/app/layouts/admin.vue +7 -7
  29. package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +2 -2
  30. package/layers/admin/app/pages/admin/[collection]/[id].vue +11 -11
  31. package/layers/admin/app/pages/admin/[collection]/index.vue +2 -2
  32. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +1 -1
  33. package/layers/admin/app/pages/admin/index.vue +3 -3
  34. package/layers/admin/app/pages/admin/login.vue +6 -6
  35. package/layers/admin/app/pages/admin/media.vue +1 -1
  36. package/layers/admin/app/pages/admin/references.vue +3 -3
  37. package/layers/admin/app/utils/list-cell.ts +46 -0
  38. package/layers/admin/nuxt.config.ts +3 -0
  39. package/layers/core/modules/component-namespace/index.ts +20 -0
  40. package/layers/core/modules/component-namespace/shared.ts +24 -0
  41. package/layers/core/nuxt.config.ts +1 -0
  42. package/layers/core/server/utils/defineCollection.ts +0 -1
  43. package/layers/core/server/utils/serialize-collection.ts +0 -2
  44. package/layers/fields/app/utils/field-factories.ts +1 -1
  45. package/layers/media/app/components/MediaConflictDialog.vue +7 -7
  46. package/layers/media/app/components/MediaDeleteDialog.vue +5 -5
  47. package/layers/media/app/components/MediaGrid.vue +3 -3
  48. package/layers/media/app/components/MediaLibrary.vue +20 -20
  49. package/layers/media/app/components/MediaNewFolderDialog.vue +7 -7
  50. package/layers/media/app/components/MediaPathBar.vue +1 -1
  51. package/layers/media/app/components/MediaPicker.vue +3 -3
  52. package/layers/media/app/components/MediaRenameDialog.vue +8 -8
  53. package/layers/media/app/components/MediaTable.vue +2 -2
  54. package/layers/media/app/components/MediaToolbar.vue +4 -4
  55. package/layers/media/app/components/MediaUploadDialog.vue +9 -9
  56. package/layers/media/app/components/MediaViewer.vue +14 -14
  57. package/layers/media/app/components/field/Media.vue +9 -9
  58. package/layers/media/app/plugins/field-media.client.ts +3 -3
  59. package/layers/media/nuxt.config.ts +5 -1
  60. package/layers/public/app/pages/[...slug].vue +2 -2
  61. package/layers/public/app/pages/__kestrel/preview.vue +1 -1
  62. package/layers/public/nuxt.config.ts +3 -0
  63. package/layers/ui/app/components/field/DeadRefNote.vue +1 -1
  64. package/layers/ui/app/components/field/Slug.vue +2 -2
  65. package/layers/ui/app/components/ui/Button.vue +1 -1
  66. package/layers/ui/app/components/ui/EmptyState.vue +1 -1
  67. package/layers/ui/app/components/ui/TextInput.vue +1 -1
  68. package/layers/ui/app/components/ui/Toasts.vue +1 -1
  69. package/layers/ui/app/composables/useToast.ts +1 -1
  70. package/layers/ui/nuxt.config.ts +5 -1
  71. 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 <FieldLayout> renderer (rows / groups /
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
- <UiField
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
- <UiSelect
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
- </UiField>
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
- <FieldLayout
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
- <UiField
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
- <UiTextInput
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
- </UiField>
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
- <UiField
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
- <UiSelect
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
- </UiField>
118
+ </KestrelUiField>
119
119
 
120
120
  <!-- Page SEO (meta title/description/noindex + Google preview). The `seo` JSON system column. -->
121
- <SeoFields
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
- <LocaleBar
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
- <PageFields
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
- <UiField class="seo-fields__title" :label="t('seo.metaTitle')" :hint="`${titleLen}/${TITLE_MAX}`">
59
+ <KestrelUiField class="seo-fields__title" :label="t('seo.metaTitle')" :hint="`${titleLen}/${TITLE_MAX}`">
60
60
  <template #default="f">
61
- <UiTextInput
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
- </UiField>
69
+ </KestrelUiField>
70
70
 
71
- <UiField class="seo-fields__desc" :label="t('seo.metaDescription')" :hint="`${descLen}/${DESC_MAX}`">
71
+ <KestrelUiField class="seo-fields__desc" :label="t('seo.metaDescription')" :hint="`${descLen}/${DESC_MAX}`">
72
72
  <template #default="f">
73
- <UiTextarea
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
- </UiField>
81
+ </KestrelUiField>
82
82
 
83
- <UiField class="seo-fields__image" :label="t('seo.socialImage')" :hint="t('seo.socialImageHint')">
83
+ <KestrelUiField class="seo-fields__image" :label="t('seo.socialImage')" :hint="t('seo.socialImageHint')">
84
84
  <template #default="f">
85
- <FieldMedia
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
- </UiField>
95
+ </KestrelUiField>
96
96
 
97
97
  <!-- Article metadata (schema.org author / datePublished / keywords). Opt-in per installation. -->
98
98
  <template v-if="articleMeta">
99
- <UiField class="seo-fields__author" :label="t('seo.author')" :hint="t('seo.authorHint')">
99
+ <KestrelUiField class="seo-fields__author" :label="t('seo.author')" :hint="t('seo.authorHint')">
100
100
  <template #default="f">
101
- <UiTextInput
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
- </UiField>
108
+ </KestrelUiField>
109
109
 
110
- <UiField class="seo-fields__published" :label="t('seo.publishedDate')" :hint="t('seo.publishedDateHint')">
110
+ <KestrelUiField class="seo-fields__published" :label="t('seo.publishedDate')" :hint="t('seo.publishedDateHint')">
111
111
  <template #default="f">
112
- <UiDatePicker
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
- </UiField>
121
+ </KestrelUiField>
122
122
 
123
- <UiField class="seo-fields__keywords" :label="t('seo.keywords')" :hint="t('seo.keywordsHint')">
123
+ <KestrelUiField class="seo-fields__keywords" :label="t('seo.keywords')" :hint="t('seo.keywordsHint')">
124
124
  <template #default="f">
125
- <UiTextInput
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
- </UiField>
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
- <UiCheckbox
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
- <UiButton 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
- <UiButton 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
- <UiButton type="button" variant="ghost" size="sm" icon="external-link" :title="previewTitle" :aria-label="previewTitle" :loading="editorRef?.previewOpening" @click="editorRef?.openPreview()" />
37
- <UiButton type="submit" :form="EDITOR_FORM_ID" variant="primary" size="sm" icon="check" :loading="saving">{{ t('common.save') }}</UiButton>
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
- <UiButton v-if="editorRef?.canPublish !== false" type="button" variant="secondary" size="sm" icon="upload" :loading="editorRef?.publishing" :disabled="saving" @click="editorRef?.publish()">{{ t('common.publish') }}</UiButton>
40
- <EditorStatus 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" />
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
- <CollectionEditor
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
- <UiBrand />
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
- <UiIcon :name="collapsed ? 'panel-left-open' : 'panel-left-close'" size="1.25rem" />
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
- <UiIcon name="home" class="rail__icon" size="1.25rem" />
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
- <AdminNav />
58
+ <KestrelAdminNav />
59
59
  </div>
60
60
 
61
61
  <div class="rail__foot">
62
- <AdminAccount />
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
- <UiIcon :name="theme === 'dark' ? 'sun' : 'moon'" class="rail__icon" size="1.25rem" />
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
- <UiToasts />
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, translatable: false, unique: false } },
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, translatable: false, unique: false } },
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 }))