@michaelthielemann/kestrel 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/layers/admin/app/components/AdminAccount.vue +3 -3
- package/layers/admin/app/components/AdminNav.vue +1 -1
- package/layers/admin/app/components/BlockFields.vue +2 -2
- package/layers/admin/app/components/BlockPreview.vue +8 -8
- package/layers/admin/app/components/BlockTree.vue +10 -10
- package/layers/admin/app/components/BlocksBody.vue +4 -4
- package/layers/admin/app/components/CollectionDeleteDialog.vue +7 -7
- package/layers/admin/app/components/CollectionEditor.vue +3 -3
- package/layers/admin/app/components/CollectionList.vue +91 -792
- package/layers/admin/app/components/CollectionListBulkBar.vue +48 -0
- package/layers/admin/app/components/CollectionListColumnsPanel.vue +37 -0
- package/layers/admin/app/components/CollectionListFilterPanel.vue +145 -0
- package/layers/admin/app/components/CollectionListPager.vue +70 -0
- package/layers/admin/app/components/CollectionListTable.vue +308 -0
- package/layers/admin/app/components/EditorStatus.vue +4 -4
- package/layers/admin/app/components/FieldsBody.vue +1 -1
- package/layers/admin/app/components/LocaleBar.vue +4 -4
- package/layers/admin/app/components/PageFields.vue +12 -12
- package/layers/admin/app/components/PageFieldsPane.vue +2 -2
- package/layers/admin/app/components/SeoFields.vue +19 -19
- package/layers/admin/app/components/SingletonEditor.vue +7 -7
- package/layers/admin/app/composables/useListBatchActions.ts +61 -0
- package/layers/admin/app/composables/useListFilterDraft.ts +128 -0
- package/layers/admin/app/composables/useListRows.ts +70 -0
- package/layers/admin/app/composables/useListSelection.ts +28 -0
- package/layers/admin/app/composables/useToolbarPanel.ts +38 -0
- package/layers/admin/app/layouts/admin.vue +7 -7
- package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +2 -2
- package/layers/admin/app/pages/admin/[collection]/[id].vue +11 -11
- package/layers/admin/app/pages/admin/[collection]/index.vue +2 -2
- package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +1 -1
- package/layers/admin/app/pages/admin/index.vue +3 -3
- package/layers/admin/app/pages/admin/login.vue +6 -6
- package/layers/admin/app/pages/admin/media.vue +1 -1
- package/layers/admin/app/pages/admin/references.vue +3 -3
- package/layers/admin/app/utils/list-cell.ts +46 -0
- package/layers/admin/nuxt.config.ts +3 -0
- package/layers/core/modules/component-namespace/index.ts +20 -0
- package/layers/core/modules/component-namespace/shared.ts +24 -0
- package/layers/core/nuxt.config.ts +1 -0
- package/layers/core/server/utils/defineCollection.ts +0 -1
- package/layers/core/server/utils/serialize-collection.ts +0 -2
- package/layers/fields/app/utils/field-factories.ts +1 -1
- package/layers/media/app/components/MediaConflictDialog.vue +7 -7
- package/layers/media/app/components/MediaDeleteDialog.vue +5 -5
- package/layers/media/app/components/MediaGrid.vue +3 -3
- package/layers/media/app/components/MediaLibrary.vue +20 -20
- package/layers/media/app/components/MediaNewFolderDialog.vue +7 -7
- package/layers/media/app/components/MediaPathBar.vue +1 -1
- package/layers/media/app/components/MediaPicker.vue +3 -3
- package/layers/media/app/components/MediaRenameDialog.vue +8 -8
- package/layers/media/app/components/MediaTable.vue +2 -2
- package/layers/media/app/components/MediaToolbar.vue +4 -4
- package/layers/media/app/components/MediaUploadDialog.vue +9 -9
- package/layers/media/app/components/MediaViewer.vue +14 -14
- package/layers/media/app/components/field/Media.vue +9 -9
- package/layers/media/app/plugins/field-media.client.ts +3 -3
- package/layers/media/nuxt.config.ts +5 -1
- package/layers/public/app/pages/[...slug].vue +2 -2
- package/layers/public/app/pages/__kestrel/preview.vue +1 -1
- package/layers/public/nuxt.config.ts +3 -0
- package/layers/ui/app/components/field/DeadRefNote.vue +1 -1
- package/layers/ui/app/components/field/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/nuxt.config.ts +5 -1
- 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
|
-
<
|
|
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
|
-
<
|
|
120
|
-
<
|
|
121
|
-
<
|
|
122
|
-
<
|
|
123
|
-
<
|
|
124
|
-
<
|
|
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
|
-
<
|
|
127
|
-
<
|
|
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
|
-
<
|
|
131
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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,
|
|
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
|
-
<
|
|
30
|
+
<KestrelUiIcon name="triangle-alert" :size="18" />
|
|
31
31
|
<span>{{ t('refs.dashAlert', { n: brokenCount }) }}</span>
|
|
32
32
|
</NuxtLink>
|
|
33
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
33
|
-
<
|
|
32
|
+
<KestrelUiAlert v-if="error" variant="error">{{ error }}</KestrelUiAlert>
|
|
33
|
+
<KestrelUiField :label="t('login.password')">
|
|
34
34
|
<template #default="f">
|
|
35
|
-
<
|
|
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
|
-
</
|
|
43
|
-
<
|
|
42
|
+
</KestrelUiField>
|
|
43
|
+
<KestrelUiButton type="submit" variant="primary" :loading="loading">{{ t('login.signIn') }}</KestrelUiButton>
|
|
44
44
|
</form>
|
|
45
45
|
</template>
|
|
46
46
|
|
|
@@ -26,14 +26,14 @@ try {
|
|
|
26
26
|
<template>
|
|
27
27
|
<section class="refs">
|
|
28
28
|
<NuxtLink to="/admin" class="refs__back">
|
|
29
|
-
<
|
|
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
|
-
<
|
|
36
|
-
<
|
|
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
|
+
}
|
|
@@ -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', '
|
|
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
|
-
<
|
|
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
|
-
<
|
|
18
|
+
<KestrelUiAlert v-if="error" variant="error">{{ error }}</KestrelUiAlert>
|
|
19
19
|
<template #footer>
|
|
20
|
-
<
|
|
21
|
-
<
|
|
22
|
-
<
|
|
23
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
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
|
-
<
|
|
37
|
+
<KestrelUiAlert v-if="error" variant="error">{{ error }}</KestrelUiAlert>
|
|
38
38
|
<template #footer>
|
|
39
|
-
<
|
|
40
|
-
<
|
|
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
|
-
</
|
|
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"><
|
|
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"><
|
|
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
|
-
<
|
|
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
|
-
<
|
|
297
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
313
|
-
<
|
|
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
|
-
</
|
|
315
|
+
</KestrelUiMenu>
|
|
316
316
|
<div v-if="hasMore" class="media-library__more">
|
|
317
|
-
<
|
|
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
|
-
<
|
|
322
|
-
<
|
|
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
|
-
<
|
|
331
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
341
|
-
<
|
|
342
|
-
<
|
|
343
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
361
|
-
<
|
|
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
|
-
<
|
|
22
|
-
<
|
|
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
|
-
<
|
|
24
|
+
<KestrelUiTextInput v-model="name" :placeholder="t('media.folderNamePlaceholder')" v-bind="f" @keydown.enter="onCreate" />
|
|
25
25
|
</template>
|
|
26
|
-
</
|
|
26
|
+
</KestrelUiField>
|
|
27
27
|
<template #footer>
|
|
28
|
-
<
|
|
29
|
-
<
|
|
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
|
-
</
|
|
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"><
|
|
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
|
-
<
|
|
9
|
-
<
|
|
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
|
-
</
|
|
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
|
-
<
|
|
21
|
-
<
|
|
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
|
-
<
|
|
23
|
+
<KestrelUiTextInput v-model="draft" v-bind="f" @keydown.enter="submit" />
|
|
24
24
|
</template>
|
|
25
|
-
</
|
|
26
|
-
<
|
|
25
|
+
</KestrelUiField>
|
|
26
|
+
<KestrelUiAlert v-if="error" variant="error">{{ error }}</KestrelUiAlert>
|
|
27
27
|
<template #footer>
|
|
28
|
-
<
|
|
29
|
-
<
|
|
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
|
-
</
|
|
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"><
|
|
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"><
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
52
|
-
<
|
|
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>
|