@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.
Files changed (81) 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/Repeater.vue +5 -5
  65. package/layers/ui/app/components/field/Slug.vue +2 -2
  66. package/layers/ui/app/components/ui/Button.vue +3 -3
  67. package/layers/ui/app/components/ui/ButtonGroup.vue +2 -2
  68. package/layers/ui/app/components/ui/Checkbox.vue +2 -2
  69. package/layers/ui/app/components/ui/Combobox.vue +5 -5
  70. package/layers/ui/app/components/ui/DatePicker.vue +2 -2
  71. package/layers/ui/app/components/ui/DateRangePicker.vue +2 -2
  72. package/layers/ui/app/components/ui/EmptyState.vue +1 -1
  73. package/layers/ui/app/components/ui/NumberInput.vue +4 -4
  74. package/layers/ui/app/components/ui/Select.vue +2 -2
  75. package/layers/ui/app/components/ui/TextInput.vue +5 -5
  76. package/layers/ui/app/components/ui/Textarea.vue +2 -2
  77. package/layers/ui/app/components/ui/TimeInput.vue +2 -2
  78. package/layers/ui/app/components/ui/Toasts.vue +4 -4
  79. package/layers/ui/app/composables/useToast.ts +1 -1
  80. package/layers/ui/nuxt.config.ts +5 -1
  81. package/package.json +1 -1
package/README.md CHANGED
@@ -129,6 +129,7 @@ where to start reading, the cross-layer seams, the gotchas). Then the per-topic
129
129
  | Doc | Covers |
130
130
  |-----|--------|
131
131
  | [architecture.md](docs/architecture.md) | **Start here** — the layer model, boot order, cross-cutting seams, per-layer guide |
132
+ | [field-types.md](docs/field-types.md) | Every built-in field type with its options, the column it becomes, what the server enforces vs. what only configures the widget |
132
133
  | [consuming-kestrel.md](docs/consuming-kestrel.md) | Using Kestrel in your own app: defining collections/fields/blocks, auto-discovery, the schema lifecycle |
133
134
  | [configuration.md](docs/configuration.md) | The single config source (`kestrel.config.ts`), every `KESTREL_*` env var, the auth/session env split |
134
135
  | [block-editing.md](docs/block-editing.md) | The block content model + the 3-pane block editor (tree · preview · fields) |
@@ -153,7 +154,7 @@ The CMS is split into Nuxt layers under `layers/`:
153
154
  lives in the repo root, dev-only, and is **not** shipped in the package.)
154
155
  - **`media`** — uploads, pluggable storage, image derivation, the media library + asset viewer.
155
156
  - **`admin`** — the editor SPA: collection list, record editor, the 3-pane block editor.
156
- - **`public`** — the SSG render path: the catch-all page, `BlockRenderer`, the JSON-LD head, the literal-key
157
+ - **`public`** — the SSG render path: the catch-all page, `KestrelBlockRenderer`, the JSON-LD head, the literal-key
157
158
  artifacts (sitemap / robots / llms.txt / llms-full.txt / redirects.json), deploy.
158
159
 
159
160
  `playground/` is a small consuming example. `templates/starter/` is what the scaffolder writes out;
@@ -27,7 +27,7 @@ defineExpose({ lang, selectLang, signOut })
27
27
  <button type="button" class="rail-account__trigger" :aria-label="t('account.label')" :title="t('account.label')">
28
28
  <span class="rail-account__avatar" aria-hidden="true">{{ initials }}</span>
29
29
  <span class="rail-account__name rail__label">{{ t('account.name') }}</span>
30
- <UiIcon name="chevron-down" class="rail-account__caret rail__label" size="1rem" />
30
+ <KestrelUiIcon name="chevron-down" class="rail-account__caret rail__label" size="1rem" />
31
31
  </button>
32
32
  </DropdownMenuTrigger>
33
33
 
@@ -56,7 +56,7 @@ defineExpose({ lang, selectLang, signOut })
56
56
  >
57
57
  <span class="rail-account__check">
58
58
  <DropdownMenuItemIndicator>
59
- <UiIcon name="check" size="1rem" />
59
+ <KestrelUiIcon name="check" size="1rem" />
60
60
  </DropdownMenuItemIndicator>
61
61
  </span>
62
62
  <span>{{ l.toUpperCase() }}</span>
@@ -66,7 +66,7 @@ defineExpose({ lang, selectLang, signOut })
66
66
  <DropdownMenuSeparator class="rail-account__sep" />
67
67
 
68
68
  <DropdownMenuItem class="rail-account__item rail-account__item--danger" @select="signOut">
69
- <UiIcon name="log-out" size="1rem" class="rail-account__item-icon" />
69
+ <KestrelUiIcon name="log-out" size="1rem" class="rail-account__item-icon" />
70
70
  <span>{{ t('nav.signOut') }}</span>
71
71
  </DropdownMenuItem>
72
72
  </DropdownMenuContent>
@@ -22,7 +22,7 @@ try {
22
22
  :class="{ 'router-link-active': isNavItemActive(route.path, `/admin/${c.name}`) }"
23
23
  :title="collapsed ? (resolveLocalized(c.label?.plural, lang) ?? c.name) : undefined"
24
24
  >
25
- <UiIcon :name="c.icon ?? 'file-text'" class="rail__icon" size="1.25rem" />
25
+ <KestrelUiIcon :name="c.icon ?? 'file-text'" class="rail__icon" size="1.25rem" />
26
26
  <span class="rail__label">{{ resolveLocalized(c.label?.plural, lang) ?? c.name }}</span>
27
27
  </NuxtLink>
28
28
  </nav>
@@ -5,7 +5,7 @@ import type { LayoutNode } from '../../../core/server/utils/field-layout'
5
5
  import type { BlockRow } from '../utils/block-tree'
6
6
  import { resolveLocalized } from '../../../ui/app/utils/localized'
7
7
 
8
- // The fields pane for the currently selected block: delegates to the shared <FieldLayout> renderer,
8
+ // The fields pane for the currently selected block: delegates to the shared <KestrelFieldLayout> renderer,
9
9
  // driven by the selected block's props. `def` is the selected block's resolved definition (the parent
10
10
  // already has it). Errors are the id-keyed BlockErrorMap entry for this block (depth-independent); edits
11
11
  // emit (key, value) for an id-addressed setProp on the shared tree.
@@ -33,7 +33,7 @@ const blockLayout = computed(() => (props.def as { fieldLayout?: LayoutNode[] }
33
33
  <template>
34
34
  <div class="block-fields">
35
35
  <p class="block-fields__title">{{ resolveLocalized(def?.label, lang) ?? block.type }}</p>
36
- <FieldLayout
36
+ <KestrelFieldLayout
37
37
  :layout="blockLayout"
38
38
  :fields="subFields"
39
39
  :values="block.props"
@@ -212,8 +212,8 @@ onUnmounted(() => { ro?.disconnect(); ro = null })
212
212
 
213
213
  <div class="block-preview__tools">
214
214
  <div class="block-preview__group">
215
- <UiTooltip>
216
- <UiButton
215
+ <KestrelUiTooltip>
216
+ <KestrelUiButton
217
217
  type="button"
218
218
  variant="ghost"
219
219
  size="sm"
@@ -222,14 +222,14 @@ onUnmounted(() => { ro?.disconnect(); ro = null })
222
222
  @click="refresh"
223
223
  />
224
224
  <template #content>{{ t('preview.refresh') }}</template>
225
- </UiTooltip>
225
+ </KestrelUiTooltip>
226
226
  </div>
227
227
 
228
228
  <span class="block-preview__sep" aria-hidden="true" />
229
229
 
230
230
  <div class="block-preview__group" role="group" :aria-label="t('preview.deviceLabel')">
231
- <UiTooltip v-for="p in presets" :key="p.key">
232
- <UiButton
231
+ <KestrelUiTooltip v-for="p in presets" :key="p.key">
232
+ <KestrelUiButton
233
233
  type="button"
234
234
  variant="ghost"
235
235
  size="sm"
@@ -240,7 +240,7 @@ onUnmounted(() => { ro?.disconnect(); ro = null })
240
240
  @click="selectPreset(p)"
241
241
  />
242
242
  <template #content>{{ t(p.label) }} · {{ p.w }} × {{ p.h === 'auto' ? t('preview.auto') : p.h }}</template>
243
- </UiTooltip>
243
+ </KestrelUiTooltip>
244
244
  </div>
245
245
 
246
246
  <span class="block-preview__sep" aria-hidden="true" />
@@ -253,7 +253,7 @@ onUnmounted(() => { ro?.disconnect(); ro = null })
253
253
  <div class="block-preview__dims">
254
254
  <!-- Either axis can be 'Auto' (fills the pane): typing a number fixes it, clearing the field
255
255
  returns it to Auto. Height is Auto under the Desktop preset. -->
256
- <UiNumberInput
256
+ <KestrelUiNumberInput
257
257
  slim
258
258
  class="block-preview__dim"
259
259
  :model-value="width === 'auto' ? null : width"
@@ -264,7 +264,7 @@ onUnmounted(() => { ro?.disconnect(); ro = null })
264
264
  @change="onDimCommit('w', $event)"
265
265
  />
266
266
  <span class="block-preview__x" aria-hidden="true">×</span>
267
- <UiNumberInput
267
+ <KestrelUiNumberInput
268
268
  slim
269
269
  class="block-preview__dim"
270
270
  :model-value="height === 'auto' ? null : height"
@@ -47,7 +47,7 @@ function pick(type: string): void {
47
47
  :aria-pressed="selectedId === null"
48
48
  @click="ctx.ops.select(null)"
49
49
  >
50
- <UiIcon name="file-text" :size="15" class="block-tree__root-icon" />
50
+ <KestrelUiIcon name="file-text" :size="15" class="block-tree__root-icon" />
51
51
  <span>{{ t('blocks.page') }}</span>
52
52
  </button>
53
53
 
@@ -71,13 +71,13 @@ function pick(type: string): void {
71
71
  >
72
72
  <span class="block-tree__node-name">{{ labelOf(block) }}</span>
73
73
  <span v-if="errorIds.has(block.id)" class="block-tree__badge" role="img" :aria-label="t('blocks.invalid')" :title="t('blocks.invalid')">!</span>
74
- <UiIcon v-if="deadRefIds.has(block.id)" name="triangle-alert" :size="14" class="block-tree__deadbadge" role="img" :aria-label="t('deadRefs.blockBadge')" :title="t('deadRefs.blockBadge')" />
74
+ <KestrelUiIcon v-if="deadRefIds.has(block.id)" name="triangle-alert" :size="14" class="block-tree__deadbadge" role="img" :aria-label="t('deadRefs.blockBadge')" :title="t('deadRefs.blockBadge')" />
75
75
  </button>
76
76
  <div class="block-tree__actions">
77
- <button type="button" class="block-tree__btn" :disabled="disabled || i === 0" :aria-label="t('blocks.moveUp', { n: i + 1 })" @click="ctx.ops.move(block.id, -1)"><UiIcon name="chevron-up" :size="15" /></button>
78
- <button type="button" class="block-tree__btn" :disabled="disabled || i === blocks.length - 1" :aria-label="t('blocks.moveDown', { n: i + 1 })" @click="ctx.ops.move(block.id, 1)"><UiIcon name="chevron-down" :size="15" /></button>
79
- <button type="button" class="block-tree__btn" :disabled="disabled" :aria-label="t('blocks.duplicate', { n: i + 1 })" @click="ctx.ops.duplicate(block.id)"><UiIcon name="copy" :size="15" /></button>
80
- <button type="button" class="block-tree__btn block-tree__btn--danger" :disabled="disabled" :aria-label="t('blocks.remove', { n: i + 1 })" @click="ctx.ops.remove(block.id)"><UiIcon name="trash" :size="15" /></button>
77
+ <button type="button" class="block-tree__btn" :disabled="disabled || i === 0" :aria-label="t('blocks.moveUp', { n: i + 1 })" @click="ctx.ops.move(block.id, -1)"><KestrelUiIcon name="chevron-up" :size="15" /></button>
78
+ <button type="button" class="block-tree__btn" :disabled="disabled || i === blocks.length - 1" :aria-label="t('blocks.moveDown', { n: i + 1 })" @click="ctx.ops.move(block.id, 1)"><KestrelUiIcon name="chevron-down" :size="15" /></button>
79
+ <button type="button" class="block-tree__btn" :disabled="disabled" :aria-label="t('blocks.duplicate', { n: i + 1 })" @click="ctx.ops.duplicate(block.id)"><KestrelUiIcon name="copy" :size="15" /></button>
80
+ <button type="button" class="block-tree__btn block-tree__btn--danger" :disabled="disabled" :aria-label="t('blocks.remove', { n: i + 1 })" @click="ctx.ops.remove(block.id)"><KestrelUiIcon name="trash" :size="15" /></button>
81
81
  </div>
82
82
  </div>
83
83
 
@@ -109,13 +109,13 @@ function pick(type: string): void {
109
109
  :aria-expanded="picking"
110
110
  @click="picking = true"
111
111
  >
112
- <UiIcon name="plus" :size="15" />
112
+ <KestrelUiIcon name="plus" :size="15" />
113
113
  <span>{{ slotName ? t('blocks.addInto', { slot: slotName }) : t('blocks.add') }}</span>
114
114
  </button>
115
115
 
116
116
  <!-- The type picker lives in a centered modal (position:fixed) so it escapes the tree pane's
117
117
  overflow-y:auto clip; reka's DialogRoot owns Escape/outside-click dismissal + focus restore. -->
118
- <UiDialog :open="picking" size="lg" :title="t('blocks.pickType')" @update:open="picking = $event">
118
+ <KestrelUiDialog :open="picking" size="lg" :title="t('blocks.pickType')" @update:open="picking = $event">
119
119
  <!-- future: category tabs + a search input mount here, above the grid -->
120
120
  <ul class="block-tree__picker" :aria-label="t('blocks.pickType')">
121
121
  <li v-for="bt in ctx.allowedTypes" :key="bt.name">
@@ -127,12 +127,12 @@ function pick(type: string): void {
127
127
  @click="pick(bt.name)"
128
128
  >
129
129
  <img v-if="bt.image" :src="bt.image" alt="" loading="lazy" class="block-tree__picker-img" />
130
- <UiIcon v-else :name="bt.icon ?? 'layout-grid'" :size="24" class="block-tree__picker-icon" />
130
+ <KestrelUiIcon v-else :name="bt.icon ?? 'layout-grid'" :size="24" class="block-tree__picker-icon" />
131
131
  <span class="block-tree__picker-label">{{ resolveLocalized(bt.label, lang) ?? bt.name }}</span>
132
132
  </button>
133
133
  </li>
134
134
  </ul>
135
- </UiDialog>
135
+ </KestrelUiDialog>
136
136
  </div>
137
137
  </div>
138
138
  </template>
@@ -69,7 +69,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
69
69
  <div class="editor3__tree-head">
70
70
  <p class="editor3__pane-label">{{ t('blocks.treeLabel') }}</p>
71
71
  </div>
72
- <BlockTree
72
+ <KestrelBlockTree
73
73
  root
74
74
  :blocks="tree.blocks.value"
75
75
  :selected-id="selectedId"
@@ -82,12 +82,12 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
82
82
 
83
83
  <aside class="editor3__preview" :aria-label="t('preview.ariaLabel')">
84
84
  <!-- The "Preview" pane label is folded into BlockPreview's own toolbar row (single-line header). -->
85
- <BlockPreview :content="content" :locale="locale" :selected-id="selectedId" @select="tree.select" />
85
+ <KestrelBlockPreview :content="content" :locale="locale" :selected-id="selectedId" @select="tree.select" />
86
86
  </aside>
87
87
 
88
88
  <section class="editor3__fields" :aria-label="t('blocks.fieldsLabel')">
89
89
  <!-- A block is selected → its fields; otherwise the page (collection) fields + locale switcher. -->
90
- <BlockFields
90
+ <KestrelBlockFields
91
91
  v-if="selectedBlock"
92
92
  :block="selectedBlock"
93
93
  :def="byName[selectedBlock.type]"
@@ -99,7 +99,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
99
99
  />
100
100
  <template v-else>
101
101
  <p class="editor3__pane-label">{{ t('blocks.pageFields') }}</p>
102
- <PageFieldsPane v-bind="ctx.pageFieldsBindings.value" v-on="ctx.pageFieldsHandlers" />
102
+ <KestrelPageFieldsPane v-bind="ctx.pageFieldsBindings.value" v-on="ctx.pageFieldsHandlers" />
103
103
  </template>
104
104
  </section>
105
105
  </div>
@@ -11,22 +11,22 @@ const { t } = useT()
11
11
  </script>
12
12
 
13
13
  <template>
14
- <UiDialog :open="open" :title="t('common.delete')" @update:open="(v) => emit('update:open', v)">
14
+ <KestrelUiDialog :open="open" :title="t('common.delete')" @update:open="(v) => emit('update:open', v)">
15
15
  <p v-if="report">{{ t('list.deleteSummary', { n: report.count }) }}</p>
16
16
  <div v-if="report && report.referencedCount > 0" class="collection-delete__warn">
17
17
  <p>{{ t('list.deleteReferrersWarn', { n: report.referencedCount }) }}</p>
18
18
  </div>
19
19
  <!-- The referrer lookup FAILED (not "none found"): inbound links are unknown, so caution rather than
20
20
  imply a safe delete. Distinct from the reference warning above, which needs a successful check. -->
21
- <UiAlert v-if="report && report.checked === false" variant="warning" class="collection-delete__caution">
21
+ <KestrelUiAlert v-if="report && report.checked === false" variant="warning" class="collection-delete__caution">
22
22
  {{ t('list.deleteRefsUnverified') }}
23
- </UiAlert>
24
- <UiAlert v-if="error" variant="error">{{ error }}</UiAlert>
23
+ </KestrelUiAlert>
24
+ <KestrelUiAlert v-if="error" variant="error">{{ error }}</KestrelUiAlert>
25
25
  <template #footer>
26
- <UiButton variant="ghost" :disabled="busy" @click="emit('update:open', false)">{{ t('common.cancel') }}</UiButton>
27
- <UiButton variant="danger" :disabled="busy" @click="emit('confirm')">{{ t('common.delete') }}</UiButton>
26
+ <KestrelUiButton variant="ghost" :disabled="busy" @click="emit('update:open', false)">{{ t('common.cancel') }}</KestrelUiButton>
27
+ <KestrelUiButton variant="danger" :disabled="busy" @click="emit('confirm')">{{ t('common.delete') }}</KestrelUiButton>
28
28
  </template>
29
- </UiDialog>
29
+ </KestrelUiDialog>
30
30
  </template>
31
31
 
32
32
  <style lang="scss" scoped>
@@ -272,11 +272,11 @@ onUnmounted(() => {
272
272
 
273
273
  <!-- The editor body is chosen by the collection's `editor` type (fields · blocks · an extension). -->
274
274
  <component :is="bodyComponent" v-if="bodyComponent" />
275
- <EditorUnsupported v-else :editor="editorType" />
275
+ <KestrelEditorUnsupported v-else :editor="editorType" />
276
276
 
277
277
  <div v-if="actions" class="editor__actions">
278
- <UiButton type="submit" variant="primary" icon="check" :loading="saving">{{ t('common.save') }}</UiButton>
279
- <UiButton type="button" variant="secondary" icon="x" :disabled="saving" @click="emit('cancel')">{{ t('common.cancel') }}</UiButton>
278
+ <KestrelUiButton type="submit" variant="primary" icon="check" :loading="saving">{{ t('common.save') }}</KestrelUiButton>
279
+ <KestrelUiButton type="button" variant="secondary" icon="x" :disabled="saving" @click="emit('cancel')">{{ t('common.cancel') }}</KestrelUiButton>
280
280
  </div>
281
281
  </form>
282
282
  </template>