@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
@@ -111,24 +111,24 @@ async function confirmDelete() {
111
111
  <section class="record">
112
112
  <div class="record__head">
113
113
  <NuxtLink :to="listPath" class="record__back">
114
- <UiIcon name="arrow-left" :size="16" />
114
+ <KestrelUiIcon name="arrow-left" :size="16" />
115
115
  <span>{{ t('editor.back', { collection: plural }) }}</span>
116
116
  </NuxtLink>
117
117
  <h1 class="record__title" :class="{ 'record__title--generic': !hasRecordTitle }">{{ heading }}</h1>
118
118
  <div class="record__actions">
119
- <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()" />
120
- <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()" />
121
- <UiButton type="button" variant="ghost" size="sm" icon="external-link" :title="previewTitle" :aria-label="previewTitle" :loading="editorRef?.previewOpening" @click="editorRef?.openPreview()" />
122
- <UiButton type="button" variant="secondary" size="sm" icon="x" :disabled="saving" @click="toList">{{ t('common.cancel') }}</UiButton>
123
- <UiButton v-if="id !== 'new'" variant="danger" size="sm" icon="trash" :loading="deleting" @click="onDelete">{{ t('common.delete') }}</UiButton>
124
- <UiButton type="submit" :form="EDITOR_FORM_ID" variant="primary" size="sm" icon="check" :loading="saving">{{ t('common.save') }}</UiButton>
119
+ <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()" />
120
+ <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()" />
121
+ <KestrelUiButton type="button" variant="ghost" size="sm" icon="external-link" :title="previewTitle" :aria-label="previewTitle" :loading="editorRef?.previewOpening" @click="editorRef?.openPreview()" />
122
+ <KestrelUiButton type="button" variant="secondary" size="sm" icon="x" :disabled="saving" @click="toList">{{ t('common.cancel') }}</KestrelUiButton>
123
+ <KestrelUiButton v-if="id !== 'new'" variant="danger" size="sm" icon="trash" :loading="deleting" @click="onDelete">{{ t('common.delete') }}</KestrelUiButton>
124
+ <KestrelUiButton type="submit" :form="EDITOR_FORM_ID" variant="primary" size="sm" icon="check" :loading="saving">{{ t('common.save') }}</KestrelUiButton>
125
125
  <!-- Publishing is its own decision: Save persists, Publish writes the static page (ADR-0008). -->
126
- <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>
127
- <EditorStatus class="record__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" />
126
+ <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>
127
+ <KestrelEditorStatus class="record__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" />
128
128
  </div>
129
129
  </div>
130
- <CollectionEditor ref="editorRef" :collection="collection" :id="id" :form-id="EDITOR_FORM_ID" :locale-param="localeParam" :group="group" :actions="false" @saved="onSaved" />
131
- <CollectionDeleteDialog
130
+ <KestrelCollectionEditor ref="editorRef" :collection="collection" :id="id" :form-id="EDITOR_FORM_ID" :locale-param="localeParam" :group="group" :actions="false" @saved="onSaved" />
131
+ <KestrelCollectionDeleteDialog
132
132
  :open="deleteOpen"
133
133
  :report="deleteReport"
134
134
  :busy="deleting"
@@ -29,12 +29,12 @@ const listLocale = computed(() => (def?.translatable ? (localeParam.value || pri
29
29
  <p v-if="!def" class="collection__missing">{{ t('collection.unknown', { name: collection }) }}</p>
30
30
 
31
31
  <template v-else-if="def.mode === 'single'">
32
- <SingletonEditor :collection="collection" :title="resolveLocalized(def.label?.singular, lang) ?? collection" :locale-param="localeParam" />
32
+ <KestrelSingletonEditor :collection="collection" :title="resolveLocalized(def.label?.singular, lang) ?? collection" :locale-param="localeParam" />
33
33
  </template>
34
34
 
35
35
  <template v-else>
36
36
  <h1 class="collection__title">{{ resolveLocalized(def.label?.plural, lang) ?? collection }}</h1>
37
- <CollectionList :schema="def" :locale="listLocale" />
37
+ <KestrelCollectionList :schema="def" :locale="listLocale" />
38
38
  </template>
39
39
  </section>
40
40
  </template>
@@ -9,7 +9,7 @@ import RecordPage from './[id].vue'
9
9
  const pagesSchema = {
10
10
  name: 'pages', mode: 'multi', translatable: false, pageLike: true, seo: false, status: true,
11
11
  blocks: { enabled: false }, label: { singular: 'Page', plural: 'Pages' },
12
- fields: { title: { type: 'text', required: true, translatable: false, unique: false } },
12
+ fields: { title: { type: 'text', required: true, unique: false } },
13
13
  }
14
14
  registerEndpoint('/api/collections', () => ({ data: [pagesSchema] }))
15
15
 
@@ -27,10 +27,10 @@ $fetch<unknown[]>('/api/references/broken')
27
27
  <h1 class="dash__title">{{ t('dash.title') }}</h1>
28
28
  <p class="dash__lede">{{ t('dash.lede') }}</p>
29
29
  <NuxtLink v-if="brokenCount" to="/admin/references" class="dash__alert">
30
- <UiIcon name="triangle-alert" :size="18" />
30
+ <KestrelUiIcon name="triangle-alert" :size="18" />
31
31
  <span>{{ t('refs.dashAlert', { n: brokenCount }) }}</span>
32
32
  </NuxtLink>
33
- <UiEmptyState
33
+ <KestrelUiEmptyState
34
34
  v-if="!collections.length"
35
35
  icon="file-text"
36
36
  :title="t('dash.empty.title')"
@@ -39,7 +39,7 @@ $fetch<unknown[]>('/api/references/broken')
39
39
  <ul v-else class="dash__grid">
40
40
  <li v-for="c in collections" :key="c.name">
41
41
  <NuxtLink :to="`/admin/${c.name}`" class="dash__card">
42
- <UiIcon :name="c.icon ?? 'file-text'" class="dash__icon" size="1.5rem" />
42
+ <KestrelUiIcon :name="c.icon ?? 'file-text'" class="dash__icon" size="1.5rem" />
43
43
  <span class="dash__name">{{ resolveLocalized(c.label?.plural, lang) ?? c.name }}</span>
44
44
  <span class="dash__mode">{{ c.mode === 'single' ? t('dash.mode.singleton') : t('dash.mode.collection') }}</span>
45
45
  </NuxtLink>
@@ -25,22 +25,22 @@ async function submit() {
25
25
  <template>
26
26
  <form class="login" @submit.prevent="submit">
27
27
  <div class="login__brand">
28
- <UiBrand />
28
+ <KestrelUiBrand />
29
29
  <span class="login__brand-word">kestrel</span>
30
30
  </div>
31
31
  <h1 class="login__title">{{ t('login.signIn') }}</h1>
32
- <UiAlert v-if="error" variant="error">{{ error }}</UiAlert>
33
- <UiField :label="t('login.password')">
32
+ <KestrelUiAlert v-if="error" variant="error">{{ error }}</KestrelUiAlert>
33
+ <KestrelUiField :label="t('login.password')">
34
34
  <template #default="f">
35
- <UiTextInput
35
+ <KestrelUiTextInput
36
36
  v-model="password"
37
37
  type="password"
38
38
  autocomplete="current-password"
39
39
  v-bind="f"
40
40
  />
41
41
  </template>
42
- </UiField>
43
- <UiButton type="submit" variant="primary" :loading="loading">{{ t('login.signIn') }}</UiButton>
42
+ </KestrelUiField>
43
+ <KestrelUiButton type="submit" variant="primary" :loading="loading">{{ t('login.signIn') }}</KestrelUiButton>
44
44
  </form>
45
45
  </template>
46
46
 
@@ -6,7 +6,7 @@ const { t } = useT()
6
6
  <template>
7
7
  <section class="media-page">
8
8
  <h1 class="media-page__title">{{ t('media.title') }}</h1>
9
- <MediaLibrary />
9
+ <KestrelMediaLibrary />
10
10
  </section>
11
11
  </template>
12
12
 
@@ -26,14 +26,14 @@ try {
26
26
  <template>
27
27
  <section class="refs">
28
28
  <NuxtLink to="/admin" class="refs__back">
29
- <UiIcon name="arrow-left" :size="16" />
29
+ <KestrelUiIcon name="arrow-left" :size="16" />
30
30
  <span>{{ t('nav.dashboard') }}</span>
31
31
  </NuxtLink>
32
32
  <h1 class="refs__title">{{ t('refs.title') }}</h1>
33
33
  <p class="refs__lede">{{ t('refs.lede') }}</p>
34
34
 
35
- <UiAlert v-if="loadError" variant="error" class="refs__error">{{ t('refs.loadError') }}</UiAlert>
36
- <UiEmptyState
35
+ <KestrelUiAlert v-if="loadError" variant="error" class="refs__error">{{ t('refs.loadError') }}</KestrelUiAlert>
36
+ <KestrelUiEmptyState
37
37
  v-else-if="!broken.length"
38
38
  icon="check"
39
39
  :title="t('refs.empty.title')"
@@ -0,0 +1,46 @@
1
+ // Cell rendering for the collection list: how a raw row value becomes the string in a table cell.
2
+ import type { ListColumn } from './list-columns'
3
+ import { humanizeFieldName } from '../../../ui/app/utils/humanize'
4
+
5
+ const dateFmt = new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'short', day: '2-digit' })
6
+ // A date-only value ('YYYY-MM-DD', from a datetime field with precision 'date') parses as UTC midnight;
7
+ // formatting it in the local zone would shift the calendar day, so pin it to UTC. Full ISO timestamps
8
+ // (createdAt/updatedAt carry a 'Z') and local datetimes keep the local-zone formatter.
9
+ const dateOnlyFmt = new Intl.DateTimeFormat(undefined, { year: 'numeric', month: 'short', day: '2-digit', timeZone: 'UTC' })
10
+
11
+ export function isDateColumn(col: ListColumn): boolean {
12
+ return col.key === 'createdAt' || col.key === 'updatedAt' || col.field?.type === 'datetime'
13
+ }
14
+
15
+ export function cellText(value: unknown): string {
16
+ if (value == null) return ''
17
+ if (typeof value === 'string') return value.replace(/<[^>]*>/g, '').slice(0, 80)
18
+ if (typeof value === 'object') return JSON.stringify(value).slice(0, 80)
19
+ return String(value)
20
+ }
21
+
22
+ export function cellDisplay(col: ListColumn, row: Record<string, unknown>): string {
23
+ const value = row[col.key]
24
+ if (isDateColumn(col) && value != null) {
25
+ const dateOnly = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)
26
+ const d = new Date(value as string)
27
+ if (!Number.isNaN(d.getTime())) return (dateOnly ? dateOnlyFmt : dateFmt).format(d)
28
+ }
29
+ return cellText(value)
30
+ }
31
+
32
+ /** A column header's text: a field column humanizes its own name, chrome columns translate a key. */
33
+ export function columnLabel(col: ListColumn, t: (key: string) => string): string {
34
+ return col.type === 'field' ? humanizeFieldName(col.name ?? col.key) : t(col.labelKey ?? col.key)
35
+ }
36
+
37
+ /** A row's human label: the first visible non-sidecar cell that has a value, else the bare id — used for
38
+ * the per-row checkbox / action aria-labels so each control names its row. */
39
+ export function rowLabel(columns: ListColumn[], row: Record<string, unknown>): string {
40
+ for (const c of columns) {
41
+ if (c.type === 'translations' || c.type === 'deadRefs') continue
42
+ const d = cellDisplay(c, row)
43
+ if (d) return d
44
+ }
45
+ return `#${row.id}`
46
+ }
@@ -1,4 +1,7 @@
1
+ import { kestrelComponents } from '../core/modules/component-namespace/shared'
2
+
1
3
  export default defineNuxtConfig({
4
+ components: [kestrelComponents(import.meta.url)],
2
5
  routeRules: {
3
6
  // The admin is a client-rendered SPA.
4
7
  '/admin/**': { ssr: false },
@@ -0,0 +1,20 @@
1
+ import { addComponentsDir, defineNuxtModule } from '@nuxt/kit'
2
+ import { resolve } from 'node:path'
3
+ import { KESTREL_COMPONENT_PREFIX, KESTREL_OVERRIDE_PRIORITY } from './shared'
4
+
5
+ /** Where a consumer places a component that replaces a shipped one, relative to their `srcDir`. */
6
+ export const KESTREL_OVERRIDE_DIR = 'Kestrel/components'
7
+
8
+ export default defineNuxtModule({
9
+ meta: { name: 'kestrel-component-namespace' },
10
+ setup(_options, nuxt) {
11
+ // The trailing `components` segment is load-bearing: Nuxt reports a missing registered directory
12
+ // (NUXT_B3001) unless the path matches its default-components pattern, which requires that suffix.
13
+ // Without it every consumer who never uses the seam would see a build warning.
14
+ addComponentsDir({
15
+ path: resolve(nuxt.options.srcDir, KESTREL_OVERRIDE_DIR),
16
+ prefix: KESTREL_COMPONENT_PREFIX,
17
+ priority: KESTREL_OVERRIDE_PRIORITY,
18
+ })
19
+ },
20
+ })
@@ -0,0 +1,24 @@
1
+ import { fileURLToPath } from 'node:url'
2
+
3
+ export const KESTREL_COMPONENT_PREFIX = 'Kestrel'
4
+
5
+ /**
6
+ * Above every layer-derived priority (Nuxt assigns `layerCount - i`, so the consumer's app dir — index 0
7
+ * — otherwise always wins). A consumer file that lands on a `Kestrel*` name anywhere but the override
8
+ * directory therefore loses instead of silently replacing the admin component.
9
+ */
10
+ export const KESTREL_LAYER_PRIORITY = 100
11
+
12
+ /** The one supported override seam, above the layers. */
13
+ export const KESTREL_OVERRIDE_PRIORITY = 200
14
+
15
+ /**
16
+ * The components directory of a Kestrel layer, namespaced. Nuxt drops a prefix the filename already
17
+ * carries, so `KestrelImg.vue` stays `KestrelImg` while `ui/Button.vue` becomes `KestrelUiButton`.
18
+ * Pass the layer's own `import.meta.url`.
19
+ */
20
+ export const kestrelComponents = (layerUrl: string) => ({
21
+ path: fileURLToPath(new URL('./app/components', layerUrl)),
22
+ prefix: KESTREL_COMPONENT_PREFIX,
23
+ priority: KESTREL_LAYER_PRIORITY,
24
+ })
@@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url'
2
2
 
3
3
  export default defineNuxtConfig({
4
4
  modules: [
5
+ fileURLToPath(new URL('./modules/component-namespace/index.ts', import.meta.url)),
5
6
  fileURLToPath(new URL('./modules/auto-discovery/index.ts', import.meta.url)),
6
7
  fileURLToPath(new URL('./modules/kestrel/index.ts', import.meta.url)),
7
8
  ],
@@ -15,7 +15,6 @@ export type Localized = string | Record<string, string>
15
15
  interface BaseFieldDef {
16
16
  required?: boolean
17
17
  default?: unknown
18
- translatable?: boolean
19
18
  unique?: boolean
20
19
  /** Add a non-unique DB index on this field's column — for a field frequently FILTERED or SORTED by but
21
20
  * not unique (e.g. `media.folder`). `unique` already implies an index, so this is only the non-unique case. */
@@ -4,7 +4,6 @@ import { resolveFieldLayout, type LayoutNode } from './field-layout'
4
4
  export interface SerializedField {
5
5
  type: FieldType
6
6
  required: boolean
7
- translatable: boolean
8
7
  unique: boolean
9
8
  /** Optional (localized) editor label; the admin falls back to the humanized field key when absent. */
10
9
  label?: Localized
@@ -66,7 +65,6 @@ export function serializeField(field: FieldDef): SerializedField {
66
65
  const out: SerializedField = {
67
66
  type: field.type,
68
67
  required: field.required ?? false,
69
- translatable: field.translatable ?? false,
70
68
  unique: field.unique ?? false,
71
69
  }
72
70
  if (isSingleRefColumn(field)) out.single = true
@@ -23,7 +23,7 @@ export interface FieldFactoryResult<C = unknown> {
23
23
 
24
24
  // Keys that belong on the FieldDef itself (not inside `options`); everything else a factory receives is a
25
25
  // type-specific option. Mirrors `BaseFieldDef` in defineCollection.ts.
26
- const BASE_KEYS = new Set(['required', 'translatable', 'unique', 'label', 'default', 'condition', 'renamedFrom', 'populate'])
26
+ const BASE_KEYS = new Set(['required', 'unique', 'label', 'default', 'condition', 'renamedFrom', 'populate'])
27
27
 
28
28
  function partition(opts: Record<string, unknown>): { base: Record<string, unknown>; options: Record<string, unknown> } {
29
29
  const base: Record<string, unknown> = {}
@@ -10,19 +10,19 @@ const title = computed(() => props.type === 'move' ? t('media.conflictTitleMove'
10
10
  </script>
11
11
 
12
12
  <template>
13
- <UiDialog :open="open" :title="title" @update:open="(v) => emit('update:open', v)">
13
+ <KestrelUiDialog :open="open" :title="title" @update:open="(v) => emit('update:open', v)">
14
14
  <p>{{ t('media.conflictItems', { count: conflicts.length, dest: dest || t('media.rootFolder') }) }}</p>
15
15
  <ul class="media-conflict__list">
16
16
  <li v-for="c in conflicts" :key="c.targetPath">{{ c.targetPath }} — {{ c.type === 'folder-exists' ? t('media.conflictFolderExists') : t('media.conflictFileExists') }}</li>
17
17
  </ul>
18
- <UiAlert v-if="error" variant="error">{{ error }}</UiAlert>
18
+ <KestrelUiAlert v-if="error" variant="error">{{ error }}</KestrelUiAlert>
19
19
  <template #footer>
20
- <UiButton :disabled="busy" @click="emit('update:open', false)">{{ t('common.cancel') }}</UiButton>
21
- <UiButton :disabled="busy" @click="emit('resolve', 'skip')">{{ t('media.skip') }}</UiButton>
22
- <UiButton :disabled="busy" @click="emit('resolve', 'overwrite')">{{ t('media.replace') }}</UiButton>
23
- <UiButton variant="primary" :disabled="busy" @click="emit('resolve', 'rename')">{{ t('media.keepBoth') }}</UiButton>
20
+ <KestrelUiButton :disabled="busy" @click="emit('update:open', false)">{{ t('common.cancel') }}</KestrelUiButton>
21
+ <KestrelUiButton :disabled="busy" @click="emit('resolve', 'skip')">{{ t('media.skip') }}</KestrelUiButton>
22
+ <KestrelUiButton :disabled="busy" @click="emit('resolve', 'overwrite')">{{ t('media.replace') }}</KestrelUiButton>
23
+ <KestrelUiButton variant="primary" :disabled="busy" @click="emit('resolve', 'rename')">{{ t('media.keepBoth') }}</KestrelUiButton>
24
24
  </template>
25
- </UiDialog>
25
+ </KestrelUiDialog>
26
26
  </template>
27
27
 
28
28
  <style lang="scss" scoped>
@@ -18,7 +18,7 @@ const usageLines = computed(() => {
18
18
  </script>
19
19
 
20
20
  <template>
21
- <UiDialog :open="open" :title="t('common.delete')" @update:open="(v) => emit('update:open', v)">
21
+ <KestrelUiDialog :open="open" :title="t('common.delete')" @update:open="(v) => emit('update:open', v)">
22
22
  <p v-if="report">
23
23
  {{ t('media.deleteSummary', {
24
24
  files: report.summary.files,
@@ -34,12 +34,12 @@ const usageLines = computed(() => {
34
34
  <li v-for="u in usageLines" :key="u.file">{{ u.file }} → {{ u.refs }}</li>
35
35
  </ul>
36
36
  </div>
37
- <UiAlert v-if="error" variant="error">{{ error }}</UiAlert>
37
+ <KestrelUiAlert v-if="error" variant="error">{{ error }}</KestrelUiAlert>
38
38
  <template #footer>
39
- <UiButton variant="ghost" :disabled="busy" @click="emit('update:open', false)">{{ t('common.cancel') }}</UiButton>
40
- <UiButton variant="danger" :disabled="busy" @click="emit('confirm')">{{ t('common.delete') }}</UiButton>
39
+ <KestrelUiButton variant="ghost" :disabled="busy" @click="emit('update:open', false)">{{ t('common.cancel') }}</KestrelUiButton>
40
+ <KestrelUiButton variant="danger" :disabled="busy" @click="emit('confirm')">{{ t('common.delete') }}</KestrelUiButton>
41
41
  </template>
42
- </UiDialog>
42
+ </KestrelUiDialog>
43
43
  </template>
44
44
 
45
45
  <style lang="scss" scoped>
@@ -26,7 +26,7 @@ function fileMeta(f: LibraryFile): string {
26
26
  <button type="button" class="media-grid__tile media-grid__tile--folder" data-test="folder-up"
27
27
  :aria-label="upLabel" @click="emit('navigate', parentPath)">
28
28
  <span class="media-grid__thumb-wrap">
29
- <span class="media-grid__thumb media-grid__thumb--folder" aria-hidden="true"><UiIcon name="folder" :size="40" /></span>
29
+ <span class="media-grid__thumb media-grid__thumb--folder" aria-hidden="true"><KestrelUiIcon name="folder" :size="40" /></span>
30
30
  </span>
31
31
  <span class="media-grid__name">..</span>
32
32
  </button>
@@ -42,7 +42,7 @@ function fileMeta(f: LibraryFile): string {
42
42
  @dragstart="(e) => emit('dragstart', item, e)"
43
43
  @dragend="emit('dragend')">
44
44
  <span class="media-grid__thumb-wrap">
45
- <span class="media-grid__thumb media-grid__thumb--folder" aria-hidden="true"><UiIcon name="folder" :size="40" /></span>
45
+ <span class="media-grid__thumb media-grid__thumb--folder" aria-hidden="true"><KestrelUiIcon name="folder" :size="40" /></span>
46
46
  <span v-if="item.folder.size != null" class="media-grid__meta" aria-hidden="true">{{ humanizeSize(item.folder.size) }}</span>
47
47
  </span>
48
48
  <span class="media-grid__name">{{ item.folder.name }}</span>
@@ -60,7 +60,7 @@ function fileMeta(f: LibraryFile): string {
60
60
  <span class="media-grid__thumb-wrap">
61
61
  <!-- Show a thumbnail for any image WITH a source: media files carry `srcset` (responsive derivatives),
62
62
  while a consumer feeding object-URLs (e.g. the secure gallery) has only `src`. Non-images → badge. -->
63
- <MediaThumb v-if="item.file.mime?.startsWith('image/') && (item.file.srcset || item.file.src)" class="media-grid__thumb"
63
+ <KestrelMediaThumb v-if="item.file.mime?.startsWith('image/') && (item.file.srcset || item.file.src)" class="media-grid__thumb"
64
64
  :src="item.file.src" :srcset="item.file.srcset" sizes="160px" :alt="item.file.alt ?? item.file.filename" :thumbhash="item.file.thumbhash" />
65
65
  <span v-else class="media-grid__badge" aria-hidden="true">{{ ext(item.file.filename) }}</span>
66
66
  <span class="media-grid__meta" aria-hidden="true">{{ fileMeta(item.file) }}</span>
@@ -293,8 +293,8 @@ const localizedMenu = computed(() => menuItems.value.map((s) => ({
293
293
  <template>
294
294
  <!-- eslint-disable-next-line vuejs-accessibility/click-events-have-key-events, vuejs-accessibility/no-static-element-interactions -- @click.self clears selection as a mouse-only bulk convenience; Space-toggle on each MediaGrid/MediaTable item already gives keyboard users the same end state -->
295
295
  <section class="media-library" @click.self="lib.clear()" @dragenter="onDragEnter" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
296
- <MediaPathBar :folder="folder" @navigate="lib.navigate" />
297
- <MediaToolbar
296
+ <KestrelMediaPathBar :folder="folder" @navigate="lib.navigate" />
297
+ <KestrelMediaToolbar
298
298
  :view="view"
299
299
  :search="search"
300
300
  :disabled="!!error"
@@ -303,23 +303,23 @@ const localizedMenu = computed(() => menuItems.value.map((s) => ({
303
303
  @upload="onUpload"
304
304
  @new-folder="newFolderOpen = true"
305
305
  />
306
- <UiMenu :items="localizedMenu" @select="onMenuSelect">
306
+ <KestrelUiMenu :items="localizedMenu" @select="onMenuSelect">
307
307
  <!-- @contextmenu.capture sets the menu target before Reka's own handler opens the menu, so menuItems is correct when the menu renders -->
308
308
  <!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -- contextmenu fires natively via Shift+F10/the Menu key when a MediaGrid/MediaTable item has focus, so this capture handler is already keyboard-reachable -->
309
309
  <div class="media-library__items" @contextmenu.capture="onContextMenu">
310
- <UiAlert v-if="error" variant="error">{{ error }}</UiAlert>
310
+ <KestrelUiAlert v-if="error" variant="error">{{ error }}</KestrelUiAlert>
311
311
  <p v-else-if="!loading && !items.length" class="media-library__empty">{{ t('media.folderEmpty') }}</p>
312
- <MediaGrid v-else-if="view === 'grid'" :items="items" :is-selected="isSelectedItem" :drop-target-path="dropFolder" :parent-path="parentPath" :up-label="t('media.parentFolder')" @navigate="lib.navigate" @select="onSelect" @open="onOpenItem" @dragstart="onItemDragStart" @dragend="onItemDragEnd" />
313
- <MediaTable v-else :items="items" :is-selected="isSelectedItem" :drop-target-path="dropFolder" :sort="sort" :parent-path="parentPath" :up-label="t('media.parentFolder')" @navigate="lib.navigate" @select="onSelect" @open="onOpenItem" @sort="lib.setSort" @dragstart="onItemDragStart" @dragend="onItemDragEnd" />
312
+ <KestrelMediaGrid v-else-if="view === 'grid'" :items="items" :is-selected="isSelectedItem" :drop-target-path="dropFolder" :parent-path="parentPath" :up-label="t('media.parentFolder')" @navigate="lib.navigate" @select="onSelect" @open="onOpenItem" @dragstart="onItemDragStart" @dragend="onItemDragEnd" />
313
+ <KestrelMediaTable v-else :items="items" :is-selected="isSelectedItem" :drop-target-path="dropFolder" :sort="sort" :parent-path="parentPath" :up-label="t('media.parentFolder')" @navigate="lib.navigate" @select="onSelect" @open="onOpenItem" @sort="lib.setSort" @dragstart="onItemDragStart" @dragend="onItemDragEnd" />
314
314
  </div>
315
- </UiMenu>
315
+ </KestrelUiMenu>
316
316
  <div v-if="hasMore" class="media-library__more">
317
- <UiButton :disabled="loading" @click="lib.loadMore">{{ t('media.loadMore') }}</UiButton>
317
+ <KestrelUiButton :disabled="loading" @click="lib.loadMore">{{ t('media.loadMore') }}</KestrelUiButton>
318
318
  </div>
319
319
  <p v-if="!clipboardEmpty" class="media-library__clipboard">
320
320
  {{ clipboardCount }} {{ clipboardCount === 1 ? t('media.clipboardItem') : t('media.clipboardItems') }} {{ clipboard?.mode === 'cut' ? t('media.clipboardCut') : t('media.clipboardCopied') }}
321
- <UiButton size="sm" :disabled="!!error" @click="onPaste(folder)">{{ t('media.pasteHere') }}</UiButton>
322
- <UiButton size="sm" variant="ghost" @click="clearClipboard()">{{ t('media.clear') }}</UiButton>
321
+ <KestrelUiButton size="sm" :disabled="!!error" @click="onPaste(folder)">{{ t('media.pasteHere') }}</KestrelUiButton>
322
+ <KestrelUiButton size="sm" variant="ghost" @click="clearClipboard()">{{ t('media.clear') }}</KestrelUiButton>
323
323
  </p>
324
324
  <p v-if="active || counts.done || counts.error || counts.skipped" class="media-library__status">
325
325
  <span v-if="active">{{ t('media.uploading') }}</span>{{ counts.done }} {{ t('media.uploadUploaded') }}<span v-if="counts.error">, {{ counts.error }} {{ t('media.uploadFailed') }}</span><span v-if="counts.skipped">, {{ counts.skipped }} {{ t('media.uploadSkipped') }}</span>
@@ -327,20 +327,20 @@ const localizedMenu = computed(() => menuItems.value.map((s) => ({
327
327
  <p class="media-library__sr-status" role="status" aria-live="polite">{{ srStatus }}</p>
328
328
  <div v-if="pick" class="media-library__pickbar">
329
329
  <span>{{ selectedFileIds.length }} {{ t('media.selected') }}</span>
330
- <UiButton variant="primary" :disabled="!selectedFileIds.length" @click="onConfirmPick">{{ t('media.useSelected') }}</UiButton>
331
- <UiButton variant="ghost" @click="emit('cancel')">{{ t('common.cancel') }}</UiButton>
330
+ <KestrelUiButton variant="primary" :disabled="!selectedFileIds.length" @click="onConfirmPick">{{ t('media.useSelected') }}</KestrelUiButton>
331
+ <KestrelUiButton variant="ghost" @click="emit('cancel')">{{ t('common.cancel') }}</KestrelUiButton>
332
332
  </div>
333
- <MediaUploadDialog
333
+ <KestrelMediaUploadDialog
334
334
  :conflicts="conflicts"
335
335
  :open="conflicts.length > 0"
336
336
  @resolve="upload.resolve"
337
337
  @resolve-all="upload.resolveAll"
338
338
  @update:open="(v) => { if (!v) upload.resolveAll('skip') }"
339
339
  />
340
- <MediaNewFolderDialog v-model:open="newFolderOpen" @create="onCreateFolder" />
341
- <MediaViewer :open="viewerOpen" :file="viewerFile" :busy="viewerBusy" :error="viewerError" @update:open="(v) => { viewerOpen = v }" @save="onSaveMeta" />
342
- <UiAlert v-if="opError && !deleteOpen && !renameOpen" variant="error">{{ opError }}</UiAlert>
343
- <MediaDeleteDialog
340
+ <KestrelMediaNewFolderDialog v-model:open="newFolderOpen" @create="onCreateFolder" />
341
+ <KestrelMediaViewer :open="viewerOpen" :file="viewerFile" :busy="viewerBusy" :error="viewerError" @update:open="(v) => { viewerOpen = v }" @save="onSaveMeta" />
342
+ <KestrelUiAlert v-if="opError && !deleteOpen && !renameOpen" variant="error">{{ opError }}</KestrelUiAlert>
343
+ <KestrelMediaDeleteDialog
344
344
  :open="deleteOpen"
345
345
  :report="deleteReport"
346
346
  :names="fileNames"
@@ -349,7 +349,7 @@ const localizedMenu = computed(() => menuItems.value.map((s) => ({
349
349
  @confirm="onConfirmDelete"
350
350
  @update:open="(v) => { if (!v) { deleteOpen = false; deleteReport = null } }"
351
351
  />
352
- <MediaRenameDialog
352
+ <KestrelMediaRenameDialog
353
353
  :open="renameOpen"
354
354
  :name="renameName"
355
355
  :busy="opsBusy"
@@ -357,8 +357,8 @@ const localizedMenu = computed(() => menuItems.value.map((s) => ({
357
357
  @rename="onConfirmRename"
358
358
  @update:open="(v) => { if (!v) { renameOpen = false; renameTarget = null } }"
359
359
  />
360
- <UiAlert v-if="relocateError && !conflictOpen" variant="error">{{ relocateError }}</UiAlert>
361
- <MediaConflictDialog
360
+ <KestrelUiAlert v-if="relocateError && !conflictOpen" variant="error">{{ relocateError }}</KestrelUiAlert>
361
+ <KestrelMediaConflictDialog
362
362
  :open="conflictOpen"
363
363
  :conflicts="pasteConflicts"
364
364
  :type="pendingRelocate?.type ?? 'move'"
@@ -18,15 +18,15 @@ function onCreate() {
18
18
  </script>
19
19
 
20
20
  <template>
21
- <UiDialog :open="open" :title="t('media.newFolder')" @update:open="(v) => emit('update:open', v)">
22
- <UiField :label="t('media.folderName')">
21
+ <KestrelUiDialog :open="open" :title="t('media.newFolder')" @update:open="(v) => emit('update:open', v)">
22
+ <KestrelUiField :label="t('media.folderName')">
23
23
  <template #default="f">
24
- <UiTextInput v-model="name" :placeholder="t('media.folderNamePlaceholder')" v-bind="f" @keydown.enter="onCreate" />
24
+ <KestrelUiTextInput v-model="name" :placeholder="t('media.folderNamePlaceholder')" v-bind="f" @keydown.enter="onCreate" />
25
25
  </template>
26
- </UiField>
26
+ </KestrelUiField>
27
27
  <template #footer>
28
- <UiButton @click="emit('update:open', false)">{{ t('common.cancel') }}</UiButton>
29
- <UiButton variant="primary" :disabled="!name.trim()" @click="onCreate">{{ t('common.create') }}</UiButton>
28
+ <KestrelUiButton @click="emit('update:open', false)">{{ t('common.cancel') }}</KestrelUiButton>
29
+ <KestrelUiButton variant="primary" :disabled="!name.trim()" @click="onCreate">{{ t('common.create') }}</KestrelUiButton>
30
30
  </template>
31
- </UiDialog>
31
+ </KestrelUiDialog>
32
32
  </template>
@@ -56,7 +56,7 @@ onUnmounted(() => clearTimeout(acTimer))
56
56
  <span class="media-pathbar__sep" aria-hidden="true">/</span>
57
57
  </template>
58
58
  </nav>
59
- <button type="button" data-test="path-edit" class="media-pathbar__edit" :aria-label="t('mediaPath.editAriaLabel')" @click="startEdit"><UiIcon name="pencil" :size="14" /></button>
59
+ <button type="button" data-test="path-edit" class="media-pathbar__edit" :aria-label="t('mediaPath.editAriaLabel')" @click="startEdit"><KestrelUiIcon name="pencil" :size="14" /></button>
60
60
  </template>
61
61
  <div v-else class="media-pathbar__editor">
62
62
  <input
@@ -5,8 +5,8 @@ const { t } = useT()
5
5
  </script>
6
6
 
7
7
  <template>
8
- <UiDialog :open="open" size="lg" :title="multiple ? t('media.picker.titleMultiple') : t('media.picker.titleSingle')" @update:open="(v) => emit('update:open', v)">
9
- <MediaLibrary
8
+ <KestrelUiDialog :open="open" size="lg" :title="multiple ? t('media.picker.titleMultiple') : t('media.picker.titleSingle')" @update:open="(v) => emit('update:open', v)">
9
+ <KestrelMediaLibrary
10
10
  pick
11
11
  :multiple="multiple"
12
12
  :accept="accept ?? 'any'"
@@ -15,5 +15,5 @@ const { t } = useT()
15
15
  @confirm="(ids) => { emit('confirm', ids); emit('update:open', false) }"
16
16
  @cancel="emit('update:open', false)"
17
17
  />
18
- </UiDialog>
18
+ </KestrelUiDialog>
19
19
  </template>
@@ -17,16 +17,16 @@ function submit() { if (canRename.value && !props.busy) emit('rename', draft.val
17
17
  </script>
18
18
 
19
19
  <template>
20
- <UiDialog :open="open" :title="t('media.rename')" @update:open="(v) => emit('update:open', v)">
21
- <UiField :label="t('media.newName')">
20
+ <KestrelUiDialog :open="open" :title="t('media.rename')" @update:open="(v) => emit('update:open', v)">
21
+ <KestrelUiField :label="t('media.newName')">
22
22
  <template #default="f">
23
- <UiTextInput v-model="draft" v-bind="f" @keydown.enter="submit" />
23
+ <KestrelUiTextInput v-model="draft" v-bind="f" @keydown.enter="submit" />
24
24
  </template>
25
- </UiField>
26
- <UiAlert v-if="error" variant="error">{{ error }}</UiAlert>
25
+ </KestrelUiField>
26
+ <KestrelUiAlert v-if="error" variant="error">{{ error }}</KestrelUiAlert>
27
27
  <template #footer>
28
- <UiButton :disabled="busy" @click="emit('update:open', false)">{{ t('common.cancel') }}</UiButton>
29
- <UiButton variant="primary" :disabled="busy || !canRename" @click="submit">{{ t('media.rename') }}</UiButton>
28
+ <KestrelUiButton :disabled="busy" @click="emit('update:open', false)">{{ t('common.cancel') }}</KestrelUiButton>
29
+ <KestrelUiButton variant="primary" :disabled="busy || !canRename" @click="submit">{{ t('media.rename') }}</KestrelUiButton>
30
30
  </template>
31
- </UiDialog>
31
+ </KestrelUiDialog>
32
32
  </template>
@@ -29,7 +29,7 @@ const dims = (f: { width?: number; height?: number }) => (f.width && f.height ?
29
29
  <tr v-if="parentPath != null" class="media-table__row media-table__row--up" data-test="row-folder-up"
30
30
  tabindex="0" :aria-label="upLabel"
31
31
  @click="emit('navigate', parentPath)" @keydown.enter.space.prevent="emit('navigate', parentPath)">
32
- <td><span aria-hidden="true"><UiIcon name="folder" :size="15" /></span> ..</td>
32
+ <td><span aria-hidden="true"><KestrelUiIcon name="folder" :size="15" /></span> ..</td>
33
33
  <td>{{ t('media.typeFolder') }}</td><td>—</td><td>—</td>
34
34
  </tr>
35
35
  <tr v-for="item in items" :key="itemKey(item)" class="media-table__row"
@@ -46,7 +46,7 @@ const dims = (f: { width?: number; height?: number }) => (f.width && f.height ?
46
46
  @dragstart="(e) => emit('dragstart', item, e)"
47
47
  @dragend="emit('dragend')">
48
48
  <template v-if="item.type === 'folder'">
49
- <td><span aria-hidden="true"><UiIcon name="folder" :size="15" /></span> {{ item.folder.name }}</td>
49
+ <td><span aria-hidden="true"><KestrelUiIcon name="folder" :size="15" /></span> {{ item.folder.name }}</td>
50
50
  <td>{{ t('media.typeFolder') }}</td><td>{{ item.folder.size != null ? humanizeSize(item.folder.size) : '—' }}</td><td>—</td>
51
51
  </template>
52
52
  <template v-else>
@@ -35,7 +35,7 @@ function onFiles(e: Event) {
35
35
 
36
36
  <template>
37
37
  <div class="media-toolbar">
38
- <UiTextInput
38
+ <KestrelUiTextInput
39
39
  class="media-toolbar__search"
40
40
  :model-value="search"
41
41
  type="search"
@@ -45,11 +45,11 @@ function onFiles(e: Event) {
45
45
  />
46
46
  <!-- View toggle + actions sit together on the right so they read as one control cluster. -->
47
47
  <div class="media-toolbar__actions">
48
- <UiButtonGroup v-model="viewModel" :options="VIEW_OPTIONS" :aria-label="t('mediaToolbar.viewAriaLabel')" />
48
+ <KestrelUiButtonGroup v-model="viewModel" :options="VIEW_OPTIONS" :aria-label="t('mediaToolbar.viewAriaLabel')" />
49
49
  <span class="media-toolbar__divider" aria-hidden="true"></span>
50
50
  <input ref="fileInput" type="file" multiple class="media-toolbar__file" :aria-label="t('mediaToolbar.upload')" @change="onFiles" />
51
- <UiButton :disabled="disabled" @click="fileInput?.click()"><UiIcon name="upload" :size="16" /> {{ t('mediaToolbar.upload') }}</UiButton>
52
- <UiButton :disabled="disabled" @click="emit('new-folder')"><UiIcon name="folder-plus" :size="16" /> {{ t('mediaToolbar.newFolder') }}</UiButton>
51
+ <KestrelUiButton :disabled="disabled" @click="fileInput?.click()"><KestrelUiIcon name="upload" :size="16" /> {{ t('mediaToolbar.upload') }}</KestrelUiButton>
52
+ <KestrelUiButton :disabled="disabled" @click="emit('new-folder')"><KestrelUiIcon name="folder-plus" :size="16" /> {{ t('mediaToolbar.newFolder') }}</KestrelUiButton>
53
53
  </div>
54
54
  </div>
55
55
  </template>