@michaelthielemann/kestrel 2.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -8
- package/layers/admin/app/components/AdminAccount.vue +3 -3
- package/layers/admin/app/components/AdminNav.vue +1 -1
- package/layers/admin/app/components/BlockFields.vue +2 -2
- package/layers/admin/app/components/BlockPreview.vue +8 -8
- package/layers/admin/app/components/BlockTree.vue +10 -10
- package/layers/admin/app/components/BlocksBody.vue +4 -4
- package/layers/admin/app/components/CollectionDeleteDialog.vue +7 -7
- package/layers/admin/app/components/CollectionEditor.vue +3 -3
- package/layers/admin/app/components/CollectionList.vue +91 -788
- package/layers/admin/app/components/CollectionListBulkBar.vue +48 -0
- package/layers/admin/app/components/CollectionListColumnsPanel.vue +37 -0
- package/layers/admin/app/components/CollectionListFilterPanel.vue +145 -0
- package/layers/admin/app/components/CollectionListPager.vue +70 -0
- package/layers/admin/app/components/CollectionListTable.vue +308 -0
- package/layers/admin/app/components/EditorStatus.vue +4 -4
- package/layers/admin/app/components/FieldsBody.vue +1 -1
- package/layers/admin/app/components/LocaleBar.vue +4 -4
- package/layers/admin/app/components/PageFields.vue +17 -12
- package/layers/admin/app/components/PageFieldsPane.vue +2 -2
- package/layers/admin/app/components/SeoFields.vue +52 -10
- package/layers/admin/app/components/SingletonEditor.vue +7 -7
- package/layers/admin/app/composables/useEditForm.ts +9 -3
- package/layers/admin/app/composables/useListBatchActions.ts +61 -0
- package/layers/admin/app/composables/useListFilterDraft.ts +128 -0
- package/layers/admin/app/composables/useListRows.ts +70 -0
- package/layers/admin/app/composables/useListSelection.ts +28 -0
- package/layers/admin/app/composables/useToolbarPanel.ts +38 -0
- package/layers/admin/app/layouts/admin.vue +7 -7
- package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +2 -2
- package/layers/admin/app/pages/admin/[collection]/[id].vue +11 -11
- package/layers/admin/app/pages/admin/[collection]/index.vue +2 -2
- package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +1 -1
- package/layers/admin/app/pages/admin/index.vue +3 -3
- package/layers/admin/app/pages/admin/login.vue +6 -6
- package/layers/admin/app/pages/admin/media.vue +1 -1
- package/layers/admin/app/pages/admin/references.vue +3 -3
- package/layers/admin/app/utils/edit-form.ts +9 -2
- package/layers/admin/app/utils/list-cell.ts +46 -0
- package/layers/admin/nuxt.config.ts +3 -0
- package/layers/core/modules/component-namespace/index.ts +20 -0
- package/layers/core/modules/component-namespace/shared.ts +24 -0
- package/layers/core/modules/kestrel/index.ts +11 -0
- package/layers/core/nuxt.config.ts +1 -0
- package/layers/core/server/api/[collection]/index.put.test.ts +76 -0
- package/layers/core/server/api/[collection]/index.put.ts +18 -1
- package/layers/core/server/utils/collection-types.ts +4 -3
- package/layers/core/server/utils/defineCollection.ts +8 -2
- package/layers/core/server/utils/kestrel-config.ts +35 -1
- package/layers/core/server/utils/seo.ts +18 -0
- package/layers/core/server/utils/serialize-collection.ts +0 -2
- package/layers/core/server/utils/write-effects.ts +40 -0
- package/layers/fields/app/utils/field-factories.ts +1 -1
- package/layers/fields/server/utils/buildCollection.ts +9 -7
- package/layers/media/app/components/KestrelImg.vue +29 -0
- package/layers/media/app/components/MediaConflictDialog.vue +7 -7
- package/layers/media/app/components/MediaDeleteDialog.vue +5 -5
- package/layers/media/app/components/MediaGrid.vue +3 -3
- package/layers/media/app/components/MediaLibrary.vue +26 -24
- package/layers/media/app/components/MediaNewFolderDialog.vue +7 -7
- package/layers/media/app/components/MediaPathBar.vue +1 -1
- package/layers/media/app/components/MediaPicker.vue +3 -3
- package/layers/media/app/components/MediaRenameDialog.vue +8 -8
- package/layers/media/app/components/MediaTable.vue +2 -2
- package/layers/media/app/components/MediaToolbar.vue +4 -4
- package/layers/media/app/components/MediaUploadDialog.vue +9 -9
- package/layers/media/app/components/MediaViewer.vue +64 -15
- package/layers/media/app/components/field/Media.vue +9 -9
- package/layers/media/app/plugins/field-media.client.ts +3 -3
- package/layers/media/app/utils/ai-disclosure.ts +19 -0
- package/layers/media/app/utils/library.ts +2 -0
- package/layers/media/nuxt.config.ts +5 -1
- package/layers/media/server/api/media/[id].patch.test.ts +66 -0
- package/layers/media/server/api/media/[id].patch.ts +25 -1
- package/layers/media/server/api/media/index.post.ts +19 -3
- package/layers/media/server/collections/media.ts +14 -0
- package/layers/media/server/utils/ai-disclosure-enabled.ts +16 -0
- package/layers/media/server/utils/ai-signal-detect.ts +155 -0
- package/layers/media/server/utils/library.ts +2 -1
- package/layers/media/server/utils/record.ts +8 -0
- package/layers/media/server/utils/resolve.ts +12 -0
- package/layers/public/app/pages/[...slug].vue +32 -3
- package/layers/public/app/pages/__kestrel/preview.vue +1 -1
- package/layers/public/app/utils/json-ld.ts +139 -0
- package/layers/public/modules/deploy-output/deploy-output.ts +19 -5
- package/layers/public/modules/prerender-routes/index.ts +5 -2
- package/layers/public/nuxt.config.ts +3 -0
- package/layers/public/server/api/route.get.ts +9 -1
- package/layers/public/server/collections/redirects.ts +75 -0
- package/layers/public/server/plugins/03.redirects.ts +37 -0
- package/layers/public/server/routes/llms-full.txt.get.ts +99 -0
- package/layers/public/server/routes/llms.txt.get.ts +1 -12
- package/layers/public/server/routes/redirects.json.get.ts +58 -0
- package/layers/public/server/routes/robots.txt.get.ts +1 -0
- package/layers/public/server/utils/llms-full.ts +125 -0
- package/layers/public/server/utils/llms.ts +13 -0
- package/layers/public/server/utils/page-resolve.ts +112 -4
- package/layers/public/server/utils/publish/invalidation.ts +48 -3
- package/layers/public/server/utils/publish/publisher.ts +11 -5
- package/layers/public/server/utils/publish/redirect-rules.ts +221 -0
- package/layers/public/server/utils/publish/redirects-artifact.ts +20 -0
- package/layers/public/server/utils/richtext-markdown.ts +260 -0
- package/layers/public/server/utils/site-url.ts +8 -0
- package/layers/public/server/utils/sitemap.ts +5 -3
- package/layers/ui/app/components/field/Choice.vue +6 -1
- package/layers/ui/app/components/field/DeadRefNote.vue +1 -1
- package/layers/ui/app/components/field/Slug.vue +2 -2
- package/layers/ui/app/components/ui/Button.vue +1 -1
- package/layers/ui/app/components/ui/EmptyState.vue +1 -1
- package/layers/ui/app/components/ui/TextInput.vue +1 -1
- package/layers/ui/app/components/ui/Toasts.vue +1 -1
- package/layers/ui/app/composables/useToast.ts +1 -1
- package/layers/ui/app/i18n/de.ts +13 -0
- package/layers/ui/app/i18n/en.ts +13 -0
- package/layers/ui/nuxt.config.ts +5 -1
- package/package.json +2 -1
- package/templates/starter/nuxt.config.ts +3 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Chrome (flex: 0 0 auto) that appears when ≥1 row is selected, shrinking the one scroll region rather
|
|
3
|
+
// than overlaying it. Publish/Unpublish are schema-driven (only when the collection has a status).
|
|
4
|
+
// NOT a live region: the selection count is announced through the list's permanent .list__sr-status
|
|
5
|
+
// region; a v-if-toggled role="status" here would double up and stay silent on first show.
|
|
6
|
+
defineProps<{ count: number; hasStatus: boolean; busy: boolean }>()
|
|
7
|
+
const emit = defineEmits<{ setStatus: [status: 'published' | 'draft']; delete: []; clear: [] }>()
|
|
8
|
+
|
|
9
|
+
const { t } = useT()
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<div class="list__bulkbar">
|
|
14
|
+
<span class="list__bulk-count">{{ t('list.selected', { n: count }) }}</span>
|
|
15
|
+
<div class="list__bulk-actions">
|
|
16
|
+
<template v-if="hasStatus">
|
|
17
|
+
<KestrelUiButton type="button" size="sm" variant="secondary" :disabled="busy" @click="emit('setStatus', 'published')">{{ t('list.bulkPublish') }}</KestrelUiButton>
|
|
18
|
+
<KestrelUiButton type="button" size="sm" variant="secondary" :disabled="busy" @click="emit('setStatus', 'draft')">{{ t('list.bulkUnpublish') }}</KestrelUiButton>
|
|
19
|
+
</template>
|
|
20
|
+
<KestrelUiButton type="button" size="sm" variant="danger" :disabled="busy" @click="emit('delete')">{{ t('list.bulkDelete') }}</KestrelUiButton>
|
|
21
|
+
<KestrelUiButton type="button" size="sm" variant="ghost" @click="emit('clear')">{{ t('list.clearSelection') }}</KestrelUiButton>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
</template>
|
|
25
|
+
|
|
26
|
+
<style lang="scss">
|
|
27
|
+
.list {
|
|
28
|
+
&__bulkbar {
|
|
29
|
+
display: flex;
|
|
30
|
+
align-items: center;
|
|
31
|
+
gap: var(--space-3);
|
|
32
|
+
padding: var(--space-2) var(--space-3);
|
|
33
|
+
border: 1px solid var(--color-border);
|
|
34
|
+
border-radius: var(--radius-md);
|
|
35
|
+
background: var(--color-surface);
|
|
36
|
+
}
|
|
37
|
+
&__bulk-count {
|
|
38
|
+
font-size: var(--text-sm);
|
|
39
|
+
font-weight: var(--weight-medium);
|
|
40
|
+
}
|
|
41
|
+
&__bulk-actions {
|
|
42
|
+
display: flex;
|
|
43
|
+
align-items: center;
|
|
44
|
+
gap: var(--space-2);
|
|
45
|
+
margin-inline-start: auto;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
</style>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { columnLabel } from '../utils/list-cell'
|
|
3
|
+
import type { ListColumn } from '../utils/list-columns'
|
|
4
|
+
|
|
5
|
+
defineProps<{ columns: ListColumn[]; visibleKeys: string[]; isDefault: boolean }>()
|
|
6
|
+
const emit = defineEmits<{ toggle: [key: string]; reset: [] }>()
|
|
7
|
+
|
|
8
|
+
const { t } = useT()
|
|
9
|
+
const colLabel = (col: ListColumn) => columnLabel(col, t)
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<div id="list-columns-panel" class="list__panel">
|
|
14
|
+
<!-- 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 -->
|
|
15
|
+
<label v-for="c in columns" :key="c.key" class="list__col-row">
|
|
16
|
+
<KestrelUiCheckbox
|
|
17
|
+
class="list__col-check"
|
|
18
|
+
:data-col="c.key"
|
|
19
|
+
:model-value="visibleKeys.includes(c.key)"
|
|
20
|
+
:disabled="visibleKeys.length === 1 && visibleKeys.includes(c.key)"
|
|
21
|
+
@update:model-value="() => emit('toggle', c.key)"
|
|
22
|
+
/>
|
|
23
|
+
<span>{{ colLabel(c) }}</span>
|
|
24
|
+
</label>
|
|
25
|
+
<button v-if="!isDefault" type="button" class="list__panel-action" @click="emit('reset')">{{ t('list.columnsReset') }}</button>
|
|
26
|
+
</div>
|
|
27
|
+
</template>
|
|
28
|
+
|
|
29
|
+
<style lang="scss">
|
|
30
|
+
.list__col-row {
|
|
31
|
+
display: flex;
|
|
32
|
+
align-items: center;
|
|
33
|
+
gap: var(--space-2);
|
|
34
|
+
font-size: var(--text-sm);
|
|
35
|
+
cursor: pointer;
|
|
36
|
+
}
|
|
37
|
+
</style>
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { FilterOp } from '../../../core/app/utils/filter-ops'
|
|
3
|
+
import type { ListFilterDraft } from '../composables/useListFilterDraft'
|
|
4
|
+
import { columnLabel } from '../utils/list-cell'
|
|
5
|
+
import type { ListColumn } from '../utils/list-columns'
|
|
6
|
+
|
|
7
|
+
// The whole draft controller arrives as ONE prop: its ten members are a single cohesive thing, and the
|
|
8
|
+
// panel must share the parent's draft state (the chip row renders off the same clauses).
|
|
9
|
+
const props = defineProps<{ columns: ListColumn[]; filter: ListFilterDraft; hasActive: boolean }>()
|
|
10
|
+
|
|
11
|
+
const { t } = useT()
|
|
12
|
+
const colLabel = (col: ListColumn) => columnLabel(col, t)
|
|
13
|
+
const draft = computed(() => props.filter.draft)
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<template>
|
|
17
|
+
<div id="list-filter-panel" class="list__panel list__panel--filter">
|
|
18
|
+
<p v-if="!columns.length" class="list__panel-empty">{{ t('list.noFilters') }}</p>
|
|
19
|
+
<div v-for="c in columns" :key="c.key" class="list__filter-row">
|
|
20
|
+
<span class="list__filter-label">{{ colLabel(c) }}</span>
|
|
21
|
+
<div class="list__filter-controls">
|
|
22
|
+
<select
|
|
23
|
+
v-if="filter.opsFor(c).length > 1"
|
|
24
|
+
class="list__filter-op"
|
|
25
|
+
:data-filter-op="c.key"
|
|
26
|
+
:aria-label="`${t('filter.operator')} — ${colLabel(c)}`"
|
|
27
|
+
:value="draft[c.key]?.op"
|
|
28
|
+
@change="filter.setOp(c.key, ($event.target as HTMLSelectElement).value as FilterOp)"
|
|
29
|
+
>
|
|
30
|
+
<option v-for="op in filter.opsFor(c)" :key="op" :value="op">{{ filter.opLabel(c.filterKind ?? 'text', op) }}</option>
|
|
31
|
+
</select>
|
|
32
|
+
|
|
33
|
+
<input
|
|
34
|
+
v-if="c.filterKind === 'datetime'"
|
|
35
|
+
type="date"
|
|
36
|
+
class="list__filter"
|
|
37
|
+
:data-filter="c.key"
|
|
38
|
+
:aria-label="colLabel(c)"
|
|
39
|
+
:value="draft[c.key]?.value ?? ''"
|
|
40
|
+
@input="filter.setValue(c.key, ($event.target as HTMLInputElement).value)"
|
|
41
|
+
>
|
|
42
|
+
<input
|
|
43
|
+
v-else-if="c.filterKind === 'number' || c.filterKind === 'ref' || c.filterKind === 'idSet'"
|
|
44
|
+
type="number"
|
|
45
|
+
class="list__filter"
|
|
46
|
+
:data-filter="c.key"
|
|
47
|
+
:aria-label="colLabel(c)"
|
|
48
|
+
:value="draft[c.key]?.value ?? ''"
|
|
49
|
+
@input="filter.setValue(c.key, ($event.target as HTMLInputElement).value)"
|
|
50
|
+
>
|
|
51
|
+
<select
|
|
52
|
+
v-else-if="c.filterKind === 'boolean'"
|
|
53
|
+
class="list__filter"
|
|
54
|
+
:data-filter="c.key"
|
|
55
|
+
:aria-label="colLabel(c)"
|
|
56
|
+
:value="draft[c.key]?.value ?? ''"
|
|
57
|
+
@change="filter.setValue(c.key, ($event.target as HTMLSelectElement).value)"
|
|
58
|
+
>
|
|
59
|
+
<option value="">—</option>
|
|
60
|
+
<option value="true">{{ t('filter.bool.true') }}</option>
|
|
61
|
+
<option value="false">{{ t('filter.bool.false') }}</option>
|
|
62
|
+
</select>
|
|
63
|
+
<!-- Single choice (`enum`) and multi choice (`stringSet`) both pick a stored VALUE from the
|
|
64
|
+
field's choices; a free-text box would let the user type the visible LABEL and match nothing. -->
|
|
65
|
+
<select
|
|
66
|
+
v-else-if="c.filterKind === 'enum' || c.filterKind === 'stringSet'"
|
|
67
|
+
class="list__filter"
|
|
68
|
+
:data-filter="c.key"
|
|
69
|
+
:aria-label="colLabel(c)"
|
|
70
|
+
:value="draft[c.key]?.value ?? ''"
|
|
71
|
+
@change="filter.setValue(c.key, ($event.target as HTMLSelectElement).value)"
|
|
72
|
+
>
|
|
73
|
+
<option value="">—</option>
|
|
74
|
+
<option v-for="o in filter.enumOptions(c)" :key="o.value" :value="o.value">{{ o.label }}</option>
|
|
75
|
+
</select>
|
|
76
|
+
<input
|
|
77
|
+
v-else
|
|
78
|
+
type="text"
|
|
79
|
+
class="list__filter"
|
|
80
|
+
:data-filter="c.key"
|
|
81
|
+
:aria-label="colLabel(c)"
|
|
82
|
+
:value="draft[c.key]?.value ?? ''"
|
|
83
|
+
:placeholder="t('list.filterPlaceholder', { name: colLabel(c) })"
|
|
84
|
+
@input="filter.setValue(c.key, ($event.target as HTMLInputElement).value)"
|
|
85
|
+
>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
<button v-if="hasActive" type="button" class="list__panel-action" @click="filter.clearAll">{{ t('list.filterClearAll') }}</button>
|
|
89
|
+
</div>
|
|
90
|
+
</template>
|
|
91
|
+
|
|
92
|
+
<style lang="scss">
|
|
93
|
+
.list {
|
|
94
|
+
// The inline filter rows need room for name + operator + value; the columns panel stays compact.
|
|
95
|
+
&__panel--filter {
|
|
96
|
+
min-width: 30rem;
|
|
97
|
+
}
|
|
98
|
+
// Name, operator and value on ONE line: a stacked label doubled the panel's height for no information.
|
|
99
|
+
&__filter-row {
|
|
100
|
+
display: flex;
|
|
101
|
+
align-items: center;
|
|
102
|
+
gap: var(--space-2);
|
|
103
|
+
font-size: var(--text-sm);
|
|
104
|
+
}
|
|
105
|
+
// Fixed basis so the names form an aligned column and the controls all start at the same x.
|
|
106
|
+
&__filter-label {
|
|
107
|
+
flex: 0 0 6.5rem;
|
|
108
|
+
min-width: 0;
|
|
109
|
+
overflow: hidden;
|
|
110
|
+
text-overflow: ellipsis;
|
|
111
|
+
white-space: nowrap;
|
|
112
|
+
color: var(--color-text-muted);
|
|
113
|
+
}
|
|
114
|
+
// Operator select + value control; the value control takes the remaining width.
|
|
115
|
+
&__filter-controls {
|
|
116
|
+
flex: 1 1 auto;
|
|
117
|
+
min-width: 0;
|
|
118
|
+
display: flex;
|
|
119
|
+
gap: var(--space-1);
|
|
120
|
+
align-items: center;
|
|
121
|
+
}
|
|
122
|
+
&__filter-op {
|
|
123
|
+
flex: 0 0 auto;
|
|
124
|
+
max-width: 45%;
|
|
125
|
+
padding: var(--space-1) var(--space-2);
|
|
126
|
+
border: 1px solid var(--color-border);
|
|
127
|
+
border-radius: var(--radius-sm);
|
|
128
|
+
background: var(--color-surface);
|
|
129
|
+
color: var(--color-text-muted);
|
|
130
|
+
font: inherit;
|
|
131
|
+
font-size: var(--text-sm);
|
|
132
|
+
}
|
|
133
|
+
&__filter {
|
|
134
|
+
width: 100%;
|
|
135
|
+
padding: var(--space-1) var(--space-2);
|
|
136
|
+
border: 1px solid var(--color-border);
|
|
137
|
+
border-radius: var(--radius-sm);
|
|
138
|
+
font: inherit;
|
|
139
|
+
}
|
|
140
|
+
&__filter-controls .list__filter {
|
|
141
|
+
flex: 1 1 auto;
|
|
142
|
+
min-width: 0;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
</style>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { PER_PAGE_OPTIONS } from '../../../core/app/utils/list-limits'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{ page: number; totalPages: number; total: number; perPage: number }>()
|
|
5
|
+
const emit = defineEmits<{ 'update:page': [page: number]; 'update:perPage': [perPage: number] }>()
|
|
6
|
+
|
|
7
|
+
const { t } = useT()
|
|
8
|
+
// The per-page `<select>` is hand-rolled here (no UiField wrapper), so the label↔control association needs
|
|
9
|
+
// an explicit id pair for static a11y analysis to see it.
|
|
10
|
+
const perPageId = useId()
|
|
11
|
+
|
|
12
|
+
function prev() {
|
|
13
|
+
if (props.page > 1) emit('update:page', props.page - 1)
|
|
14
|
+
}
|
|
15
|
+
function next() {
|
|
16
|
+
if (props.page < props.totalPages) emit('update:page', props.page + 1)
|
|
17
|
+
}
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<div class="list__pager">
|
|
22
|
+
<KestrelUiButton type="button" size="sm" :disabled="page <= 1" @click="prev">{{ t('list.prev') }}</KestrelUiButton>
|
|
23
|
+
<span class="list__page">{{ t('list.page', { page, totalPages, total }) }}</span>
|
|
24
|
+
<KestrelUiButton type="button" size="sm" :disabled="page >= totalPages" @click="next">{{ t('list.next') }}</KestrelUiButton>
|
|
25
|
+
<label class="list__perpage" :for="perPageId">
|
|
26
|
+
<span class="list__perpage-label">{{ t('list.perPage') }}</span>
|
|
27
|
+
<select
|
|
28
|
+
:id="perPageId"
|
|
29
|
+
class="list__perpage-select"
|
|
30
|
+
:value="perPage"
|
|
31
|
+
:aria-label="t('list.perPage')"
|
|
32
|
+
@change="emit('update:perPage', Number(($event.target as HTMLSelectElement).value))"
|
|
33
|
+
>
|
|
34
|
+
<option v-for="n in PER_PAGE_OPTIONS" :key="n" :value="n">{{ n }}</option>
|
|
35
|
+
</select>
|
|
36
|
+
</label>
|
|
37
|
+
</div>
|
|
38
|
+
</template>
|
|
39
|
+
|
|
40
|
+
<style lang="scss">
|
|
41
|
+
.list {
|
|
42
|
+
&__pager {
|
|
43
|
+
display: flex;
|
|
44
|
+
align-items: center;
|
|
45
|
+
gap: var(--space-3);
|
|
46
|
+
}
|
|
47
|
+
&__page {
|
|
48
|
+
font-size: var(--text-sm);
|
|
49
|
+
color: var(--color-text-muted);
|
|
50
|
+
}
|
|
51
|
+
// Per-page size selector sits at the far end of the pager.
|
|
52
|
+
&__perpage {
|
|
53
|
+
display: inline-flex;
|
|
54
|
+
align-items: center;
|
|
55
|
+
gap: var(--space-2);
|
|
56
|
+
margin-inline-start: auto;
|
|
57
|
+
font-size: var(--text-sm);
|
|
58
|
+
color: var(--color-text-muted);
|
|
59
|
+
}
|
|
60
|
+
&__perpage-select {
|
|
61
|
+
padding: var(--space-1) var(--space-2);
|
|
62
|
+
border: 1px solid var(--color-border);
|
|
63
|
+
border-radius: var(--radius-sm);
|
|
64
|
+
background: var(--color-surface);
|
|
65
|
+
color: var(--color-text);
|
|
66
|
+
font: inherit;
|
|
67
|
+
font-size: var(--text-sm);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
</style>
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { sortDirection } from '../utils/list-query'
|
|
3
|
+
import { cellDisplay, columnLabel, rowLabel as labelForRow } from '../utils/list-cell'
|
|
4
|
+
import type { ListColumn } from '../utils/list-columns'
|
|
5
|
+
|
|
6
|
+
const props = defineProps<{
|
|
7
|
+
rows: Record<string, unknown>[]
|
|
8
|
+
columns: ListColumn[]
|
|
9
|
+
collection: string
|
|
10
|
+
localeQuery: string
|
|
11
|
+
sort: string
|
|
12
|
+
busy: boolean
|
|
13
|
+
selected: Set<number>
|
|
14
|
+
allSelected: boolean
|
|
15
|
+
headerIndeterminate: boolean
|
|
16
|
+
}>()
|
|
17
|
+
const emit = defineEmits<{
|
|
18
|
+
sort: [key: string]
|
|
19
|
+
toggleRow: [id: number, on: boolean]
|
|
20
|
+
toggleAll: [on: boolean]
|
|
21
|
+
duplicate: [id: number]
|
|
22
|
+
delete: [ids: number[]]
|
|
23
|
+
}>()
|
|
24
|
+
|
|
25
|
+
const { t } = useT()
|
|
26
|
+
const { locales } = useContentLocales()
|
|
27
|
+
|
|
28
|
+
const colLabel = (col: ListColumn) => columnLabel(col, t)
|
|
29
|
+
const rowLabel = (row: Record<string, unknown>) => labelForRow(props.columns, row)
|
|
30
|
+
|
|
31
|
+
function arrow(field: string) {
|
|
32
|
+
const dir = sortDirection(props.sort, field)
|
|
33
|
+
return dir === 'asc' ? ' ▲' : dir === 'desc' ? ' ▼' : ''
|
|
34
|
+
}
|
|
35
|
+
// Programmatic sort state for AT (the visual arrow is aria-hidden) — WCAG 1.3.1 / 4.1.2.
|
|
36
|
+
function ariaSort(field: string): 'ascending' | 'descending' | 'none' {
|
|
37
|
+
const dir = sortDirection(props.sort, field)
|
|
38
|
+
return dir === 'asc' ? 'ascending' : dir === 'desc' ? 'descending' : 'none'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Per-row translation status (the list query attaches `$translations`: locale → sibling id, or null).
|
|
42
|
+
// Present locales link to their sibling; missing ones offer create-and-link, carrying the translation group.
|
|
43
|
+
function rowTranslations(row: Record<string, unknown>) {
|
|
44
|
+
const map = (row.$translations ?? {}) as Record<string, number | null>
|
|
45
|
+
const group = row.translationGroup as string | undefined
|
|
46
|
+
return locales.map((loc) => {
|
|
47
|
+
const id = map[loc]
|
|
48
|
+
return id != null
|
|
49
|
+
? { loc, present: true, to: `/admin/${props.collection}/${id}?locale=${loc}` }
|
|
50
|
+
: { loc, present: false, to: `/admin/${props.collection}/new?locale=${loc}${group ? `&group=${group}` : ''}` }
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<template>
|
|
56
|
+
<table class="list__table">
|
|
57
|
+
<thead>
|
|
58
|
+
<tr>
|
|
59
|
+
<th class="list__select-th" scope="col">
|
|
60
|
+
<KestrelUiCheckbox
|
|
61
|
+
:model-value="allSelected"
|
|
62
|
+
:indeterminate="headerIndeterminate"
|
|
63
|
+
:aria-label="t('list.selectAll')"
|
|
64
|
+
@update:model-value="(v) => emit('toggleAll', v)"
|
|
65
|
+
/>
|
|
66
|
+
</th>
|
|
67
|
+
<!-- Row actions: an always-visible, left-frozen chrome column placed right after Select. Its
|
|
68
|
+
accessible name is real visually-hidden text, not aria-label on a static <th> (which several
|
|
69
|
+
screen-reader / browser pairs ignore on non-interactive table cells). -->
|
|
70
|
+
<th class="list__actions-th" scope="col">
|
|
71
|
+
<span class="list__vh">{{ t('a11y.rowActions') }}</span>
|
|
72
|
+
</th>
|
|
73
|
+
<th
|
|
74
|
+
v-for="c in columns"
|
|
75
|
+
:key="c.key"
|
|
76
|
+
scope="col"
|
|
77
|
+
:aria-sort="c.sortable ? ariaSort(c.key) : undefined"
|
|
78
|
+
:class="{ 'list__th-narrow': c.type === 'translations' || c.type === 'deadRefs' }"
|
|
79
|
+
>
|
|
80
|
+
<button v-if="c.sortable" type="button" class="list__sort" @click="emit('sort', c.key)">{{ colLabel(c) }}<span aria-hidden="true">{{ arrow(c.key) }}</span></button>
|
|
81
|
+
<span v-else>{{ colLabel(c) }}</span>
|
|
82
|
+
</th>
|
|
83
|
+
</tr>
|
|
84
|
+
</thead>
|
|
85
|
+
<tbody>
|
|
86
|
+
<tr v-for="row in rows" :key="String(row.id)" class="list__row">
|
|
87
|
+
<td class="list__select-cell">
|
|
88
|
+
<KestrelUiCheckbox
|
|
89
|
+
:model-value="selected.has(Number(row.id))"
|
|
90
|
+
:aria-label="t('list.selectRow', { name: rowLabel(row) })"
|
|
91
|
+
@update:model-value="(v) => emit('toggleRow', Number(row.id), v)"
|
|
92
|
+
/>
|
|
93
|
+
</td>
|
|
94
|
+
<td class="list__actions-cell">
|
|
95
|
+
<div class="list__row-actions">
|
|
96
|
+
<NuxtLink :to="`/admin/${collection}/${row.id}${localeQuery}`" class="list__action-btn" :aria-label="t('list.rowEdit', { name: rowLabel(row) })">
|
|
97
|
+
<KestrelUiIcon name="pencil" :size="15" />
|
|
98
|
+
</NuxtLink>
|
|
99
|
+
<button type="button" class="list__action-btn" :disabled="busy" :aria-label="t('list.rowDuplicate', { name: rowLabel(row) })" @click="emit('duplicate', Number(row.id))">
|
|
100
|
+
<KestrelUiIcon name="copy" :size="15" />
|
|
101
|
+
</button>
|
|
102
|
+
<button type="button" class="list__action-btn list__action-btn--danger" :disabled="busy" :aria-label="t('list.rowDelete', { name: rowLabel(row) })" @click="emit('delete', [Number(row.id)])">
|
|
103
|
+
<KestrelUiIcon name="trash" :size="15" />
|
|
104
|
+
</button>
|
|
105
|
+
</div>
|
|
106
|
+
</td>
|
|
107
|
+
<td v-for="c in columns" :key="c.key" :class="{ 'list__narrow-cell': c.type === 'translations' || c.type === 'deadRefs' }">
|
|
108
|
+
<span v-if="c.type === 'deadRefs'" class="list__deadrefs">
|
|
109
|
+
<KestrelUiIcon
|
|
110
|
+
v-if="row.$hasDeadRefs"
|
|
111
|
+
name="triangle-alert"
|
|
112
|
+
:size="15"
|
|
113
|
+
class="list__deadref-icon"
|
|
114
|
+
role="img"
|
|
115
|
+
:aria-label="t('deadRefs.listWarning')"
|
|
116
|
+
:title="t('deadRefs.listWarning')"
|
|
117
|
+
/>
|
|
118
|
+
</span>
|
|
119
|
+
<span v-else-if="c.type === 'translations'" class="list__transl">
|
|
120
|
+
<NuxtLink
|
|
121
|
+
v-for="tr in rowTranslations(row)"
|
|
122
|
+
:key="tr.loc"
|
|
123
|
+
:to="tr.to"
|
|
124
|
+
:data-loc="tr.loc"
|
|
125
|
+
class="list__badge"
|
|
126
|
+
:class="tr.present ? 'list__badge--present' : 'list__badge--missing'"
|
|
127
|
+
:title="tr.present ? t('list.editLocale', { loc: tr.loc.toUpperCase() }) : t('list.createLocale', { loc: tr.loc.toUpperCase() })"
|
|
128
|
+
:aria-label="tr.present ? t('list.editLocale', { loc: tr.loc.toUpperCase() }) : t('list.createLocale', { loc: tr.loc.toUpperCase() })"
|
|
129
|
+
>{{ tr.loc.toUpperCase() }}</NuxtLink>
|
|
130
|
+
</span>
|
|
131
|
+
<template v-else>{{ cellDisplay(c, row) }}</template>
|
|
132
|
+
</td>
|
|
133
|
+
</tr>
|
|
134
|
+
</tbody>
|
|
135
|
+
</table>
|
|
136
|
+
</template>
|
|
137
|
+
|
|
138
|
+
<style lang="scss">
|
|
139
|
+
// Visually-hidden text for a static cell that must be announced but not shown (the Actions column header).
|
|
140
|
+
// Chosen over aria-label, which is unreliably announced on non-interactive table cells.
|
|
141
|
+
.list__vh {
|
|
142
|
+
position: absolute;
|
|
143
|
+
width: 1px;
|
|
144
|
+
height: 1px;
|
|
145
|
+
padding: 0;
|
|
146
|
+
margin: -1px;
|
|
147
|
+
overflow: hidden;
|
|
148
|
+
clip: rect(0, 0, 0, 0);
|
|
149
|
+
white-space: nowrap;
|
|
150
|
+
border: 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.list {
|
|
154
|
+
&__table {
|
|
155
|
+
width: 100%;
|
|
156
|
+
border-collapse: collapse;
|
|
157
|
+
font-size: var(--text-sm);
|
|
158
|
+
|
|
159
|
+
th,
|
|
160
|
+
td {
|
|
161
|
+
text-align: start;
|
|
162
|
+
padding: var(--space-2) var(--space-3);
|
|
163
|
+
border-bottom: 1px solid var(--color-border);
|
|
164
|
+
vertical-align: top;
|
|
165
|
+
}
|
|
166
|
+
// Keep the column headers visible while the rows scroll inside .list__scroll. A solid background is
|
|
167
|
+
// required so scrolling rows don't show through. Part of the frozen-column z-index stack (see the
|
|
168
|
+
// &__select-* / &__actions-* rules): scrolling body td 0 < frozen body td 1 < scrolling header th 2 <
|
|
169
|
+
// frozen corner th 3, so nothing bleeds through on either scroll axis.
|
|
170
|
+
thead th {
|
|
171
|
+
position: sticky;
|
|
172
|
+
top: 0;
|
|
173
|
+
z-index: 2; // scrolling header: above scrolling body cells (0) and the frozen body columns (1)
|
|
174
|
+
background: var(--color-bg);
|
|
175
|
+
}
|
|
176
|
+
// Frozen corner: the select + actions headers are sticky on BOTH axes, so they must sit above every
|
|
177
|
+
// scrolling header AND every frozen body column where the two frozen edges cross.
|
|
178
|
+
thead th.list__select-th,
|
|
179
|
+
thead th.list__actions-th {
|
|
180
|
+
z-index: 3;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
&__sort {
|
|
184
|
+
background: none;
|
|
185
|
+
border: 0;
|
|
186
|
+
padding: 0;
|
|
187
|
+
font: inherit;
|
|
188
|
+
font-weight: var(--weight-medium);
|
|
189
|
+
cursor: pointer;
|
|
190
|
+
color: var(--color-text);
|
|
191
|
+
}
|
|
192
|
+
// Compact, de-emphasised info columns (translations · dead-reference warning).
|
|
193
|
+
&__th-narrow,
|
|
194
|
+
&__narrow-cell {
|
|
195
|
+
width: 1%;
|
|
196
|
+
white-space: nowrap;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Dead-reference warning: an amber triangle, shown only on rows holding a stale reference.
|
|
200
|
+
&__deadrefs {
|
|
201
|
+
display: inline-flex;
|
|
202
|
+
}
|
|
203
|
+
&__deadref-icon {
|
|
204
|
+
color: var(--color-warning);
|
|
205
|
+
}
|
|
206
|
+
&__transl {
|
|
207
|
+
display: inline-flex;
|
|
208
|
+
gap: 2px;
|
|
209
|
+
flex-wrap: wrap;
|
|
210
|
+
}
|
|
211
|
+
&__badge {
|
|
212
|
+
display: inline-flex;
|
|
213
|
+
align-items: center;
|
|
214
|
+
justify-content: center;
|
|
215
|
+
min-width: 1.4rem;
|
|
216
|
+
padding: 0 3px;
|
|
217
|
+
border: 1px solid transparent;
|
|
218
|
+
border-radius: var(--radius-sm);
|
|
219
|
+
font-size: 0.625rem;
|
|
220
|
+
font-weight: var(--weight-medium);
|
|
221
|
+
line-height: 1.5;
|
|
222
|
+
letter-spacing: 0.02em;
|
|
223
|
+
text-decoration: none;
|
|
224
|
+
|
|
225
|
+
&--present {
|
|
226
|
+
background: var(--color-primary-soft);
|
|
227
|
+
color: var(--color-primary);
|
|
228
|
+
}
|
|
229
|
+
&--missing {
|
|
230
|
+
color: var(--color-text-muted);
|
|
231
|
+
border-color: var(--color-border);
|
|
232
|
+
border-style: dashed;
|
|
233
|
+
}
|
|
234
|
+
&:focus-visible {
|
|
235
|
+
outline: 2px solid var(--color-focus);
|
|
236
|
+
outline-offset: -2px;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Selection column — the first frozen chrome column, pinned to the left with `position: sticky` inside
|
|
241
|
+
// the existing .list__scroll (no new scroll container). Opaque `var(--color-bg)` so horizontally-scrolled
|
|
242
|
+
// data can't bleed through; z-index 1 lifts the frozen body cell above the scrolling body cells.
|
|
243
|
+
&__select-th,
|
|
244
|
+
&__select-cell {
|
|
245
|
+
position: sticky;
|
|
246
|
+
left: 0;
|
|
247
|
+
z-index: 1;
|
|
248
|
+
width: var(--list-col-select);
|
|
249
|
+
white-space: nowrap;
|
|
250
|
+
vertical-align: middle;
|
|
251
|
+
background: var(--color-bg);
|
|
252
|
+
}
|
|
253
|
+
// Row-actions column — the second frozen column, sitting immediately after Select (its `left` offset is
|
|
254
|
+
// exactly the select column's width). Always visible; snug `width: 1%` so the three icon buttons never
|
|
255
|
+
// steal width from the Title column.
|
|
256
|
+
&__actions-th,
|
|
257
|
+
&__actions-cell {
|
|
258
|
+
position: sticky;
|
|
259
|
+
left: var(--list-col-select);
|
|
260
|
+
z-index: 1;
|
|
261
|
+
width: 1%;
|
|
262
|
+
white-space: nowrap;
|
|
263
|
+
vertical-align: middle;
|
|
264
|
+
background: var(--color-bg);
|
|
265
|
+
}
|
|
266
|
+
// A plain, always-visible inline row of icon buttons (Edit → Duplicate → Delete; destructive last, never
|
|
267
|
+
// default focus). BlockTree.vue deliberately keeps its own absolute overlay: a tree row is a single flex
|
|
268
|
+
// row with no column grid — there is no cell to place actions in.
|
|
269
|
+
&__row-actions {
|
|
270
|
+
display: inline-flex;
|
|
271
|
+
align-items: center;
|
|
272
|
+
gap: 1px;
|
|
273
|
+
}
|
|
274
|
+
&__action-btn {
|
|
275
|
+
display: flex;
|
|
276
|
+
align-items: center;
|
|
277
|
+
justify-content: center;
|
|
278
|
+
width: 1.5rem;
|
|
279
|
+
height: 1.5rem;
|
|
280
|
+
border: 0;
|
|
281
|
+
border-radius: var(--radius-sm);
|
|
282
|
+
background: transparent;
|
|
283
|
+
color: var(--color-text-muted); /* meaningful icon control at rest → ≥3:1 (SC 1.4.11) */
|
|
284
|
+
text-decoration: none;
|
|
285
|
+
cursor: pointer;
|
|
286
|
+
transition:
|
|
287
|
+
background-color var(--motion-fast) var(--ease-standard),
|
|
288
|
+
color var(--motion-fast) var(--ease-standard);
|
|
289
|
+
|
|
290
|
+
&:hover {
|
|
291
|
+
background: var(--color-hover);
|
|
292
|
+
color: var(--color-text);
|
|
293
|
+
}
|
|
294
|
+
&:focus-visible {
|
|
295
|
+
outline: 2px solid var(--color-focus);
|
|
296
|
+
outline-offset: -2px;
|
|
297
|
+
}
|
|
298
|
+
&--danger:hover {
|
|
299
|
+
color: var(--color-danger);
|
|
300
|
+
}
|
|
301
|
+
&:disabled {
|
|
302
|
+
opacity: 0.5;
|
|
303
|
+
cursor: default;
|
|
304
|
+
pointer-events: none;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
</style>
|
|
@@ -92,7 +92,7 @@ const live = computed<{ tone: 'green' | 'red' | 'amber' | 'blue' | 'neutral'; wo
|
|
|
92
92
|
Visually hidden; not a tooltip. -->
|
|
93
93
|
<span class="editor-status-sr" role="status" aria-live="polite">{{ save.detail }}</span>
|
|
94
94
|
|
|
95
|
-
<
|
|
95
|
+
<KestrelUiTooltip side="bottom">
|
|
96
96
|
<!-- A focusable status indicator, NOT a <button>: it has no action, and a button whose accessible
|
|
97
97
|
name contains "Saved"/"Unsaved" would collide with `getByRole('button', {name:'Save'})`. aria-label
|
|
98
98
|
leads with the visible word so it stays part of the accessible name, then adds the detail for AT. -->
|
|
@@ -105,9 +105,9 @@ const live = computed<{ tone: 'green' | 'red' | 'amber' | 'blue' | 'neutral'; wo
|
|
|
105
105
|
<strong class="editor-status-tip__detail">{{ save.detail }}</strong>
|
|
106
106
|
</span>
|
|
107
107
|
</template>
|
|
108
|
-
</
|
|
108
|
+
</KestrelUiTooltip>
|
|
109
109
|
|
|
110
|
-
<
|
|
110
|
+
<KestrelUiTooltip v-if="live" side="bottom">
|
|
111
111
|
<span class="editor-status-live" tabindex="0" :data-tone="live.tone" :aria-label="`${live.word} — ${live.detail}`">
|
|
112
112
|
<span class="editor-status-live__dot" aria-hidden="true" />
|
|
113
113
|
<span class="editor-status-live__word">{{ live.word }}</span>
|
|
@@ -119,7 +119,7 @@ const live = computed<{ tone: 'green' | 'red' | 'amber' | 'blue' | 'neutral'; wo
|
|
|
119
119
|
<span v-if="live.tone === 'red' && live.error" class="editor-status-tip__err">{{ live.error }}</span>
|
|
120
120
|
</span>
|
|
121
121
|
</template>
|
|
122
|
-
</
|
|
122
|
+
</KestrelUiTooltip>
|
|
123
123
|
</span>
|
|
124
124
|
</template>
|
|
125
125
|
|
|
@@ -6,7 +6,7 @@ const ctx = useEditorFormContext()
|
|
|
6
6
|
|
|
7
7
|
<template>
|
|
8
8
|
<div class="editor__flat">
|
|
9
|
-
<
|
|
9
|
+
<KestrelPageFieldsPane v-bind="ctx.pageFieldsBindings.value" v-on="ctx.pageFieldsHandlers" />
|
|
10
10
|
</div>
|
|
11
11
|
</template>
|
|
12
12
|
|
|
@@ -38,11 +38,11 @@ const { t } = useT()
|
|
|
38
38
|
class="locale-bar__item locale-bar__btn"
|
|
39
39
|
:to="`/admin/${collection}?locale=${loc}`"
|
|
40
40
|
:aria-label="t('localeBar.editLocale', { loc: up(loc) })"
|
|
41
|
-
>{{ up(loc) }}<
|
|
41
|
+
>{{ up(loc) }}<KestrelUiIcon name="pencil" :size="14" /></NuxtLink>
|
|
42
42
|
|
|
43
43
|
<span v-else-if="existingId(loc) !== null" class="locale-bar__item">
|
|
44
|
-
<NuxtLink class="locale-bar__btn" :to="`/admin/${collection}/${existingId(loc)}?locale=${loc}`" :aria-label="t('localeBar.editLocale', { loc: up(loc) })">{{ up(loc) }}<
|
|
45
|
-
<button type="button" class="locale-bar__btn locale-bar__btn--copy" :aria-label="t('localeBar.copyInto', { loc: up(loc), current: up(current) })" @click="emit('copyFrom', loc)"><
|
|
44
|
+
<NuxtLink class="locale-bar__btn" :to="`/admin/${collection}/${existingId(loc)}?locale=${loc}`" :aria-label="t('localeBar.editLocale', { loc: up(loc) })">{{ up(loc) }}<KestrelUiIcon name="pencil" :size="14" /></NuxtLink>
|
|
45
|
+
<button type="button" class="locale-bar__btn locale-bar__btn--copy" :aria-label="t('localeBar.copyInto', { loc: up(loc), current: up(current) })" @click="emit('copyFrom', loc)"><KestrelUiIcon name="copy" :size="14" /></button>
|
|
46
46
|
</span>
|
|
47
47
|
|
|
48
48
|
<NuxtLink
|
|
@@ -50,7 +50,7 @@ const { t } = useT()
|
|
|
50
50
|
class="locale-bar__item locale-bar__btn locale-bar__btn--add"
|
|
51
51
|
:to="createLink(loc)"
|
|
52
52
|
:aria-label="t('localeBar.createTranslation', { loc: up(loc) })"
|
|
53
|
-
>{{ up(loc) }}<
|
|
53
|
+
>{{ up(loc) }}<KestrelUiIcon name="plus" :size="14" /></NuxtLink>
|
|
54
54
|
</template>
|
|
55
55
|
</div>
|
|
56
56
|
</template>
|