@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
|
@@ -7,13 +7,31 @@ export interface SeoMeta {
|
|
|
7
7
|
noindex?: boolean
|
|
8
8
|
/** Media id of the social-share image (og:image / twitter card); resolved under `seo.$media.image`. */
|
|
9
9
|
image?: number | null
|
|
10
|
+
/** Article metadata — schema.org `author` / `datePublished` / `keywords` on the page's JSON-LD node.
|
|
11
|
+
* Only offered and only published when `kestrel.seo.articleMeta` is on; the column always ROUND-TRIPS
|
|
12
|
+
* them, so turning the flag off hides and unpublishes existing values instead of destroying them. */
|
|
13
|
+
author?: string
|
|
14
|
+
/** ISO date (`YYYY-MM-DD`) or ISO datetime; `''` is the editor's cleared state. */
|
|
15
|
+
publishedDate?: string
|
|
16
|
+
/** Free-form comma-separated list (the spelling schema.org accepts verbatim). */
|
|
17
|
+
keywords?: string
|
|
10
18
|
}
|
|
11
19
|
|
|
20
|
+
// A date is validated here rather than at emission time so a mistyped value surfaces in the editor,
|
|
21
|
+
// where it can be fixed — not silently as a missing `datePublished` in an artifact nobody looks at.
|
|
22
|
+
const isoDateish = z.string().refine(
|
|
23
|
+
(v) => v === '' || /^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?(\.\d+)?(Z|[+-]\d{2}:?\d{2})?)?$/.test(v),
|
|
24
|
+
{ message: 'Expected a date (YYYY-MM-DD)' },
|
|
25
|
+
)
|
|
26
|
+
|
|
12
27
|
export const seoSchema = z.object({
|
|
13
28
|
title: z.string().optional(),
|
|
14
29
|
description: z.string().optional(),
|
|
15
30
|
noindex: z.boolean().optional(),
|
|
16
31
|
image: z.number().int().positive().nullish(),
|
|
32
|
+
author: z.string().optional(),
|
|
33
|
+
publishedDate: isoDateish.optional(),
|
|
34
|
+
keywords: z.string().optional(),
|
|
17
35
|
})
|
|
18
36
|
|
|
19
37
|
// The synthetic field set the row populator walks over the `seo` system column (PROPS key-mode), so the
|
|
@@ -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
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { CollectionDef } from './defineCollection'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Post-write EFFECTS — the fail-able sibling of the write-event bus (`write-events.ts`).
|
|
5
|
+
*
|
|
6
|
+
* A write LISTENER is fire-and-forget on purpose: a publish failure must never break a content write, so
|
|
7
|
+
* `emitWrite` swallows throws. That is exactly wrong for a write whose external side effect is part of
|
|
8
|
+
* the contract — a redirects artifact the edge serves is stale the moment it fails, and an editor who saw
|
|
9
|
+
* a green save has no way to know. An EFFECT is awaited by the route and its rejection becomes the save's
|
|
10
|
+
* error response.
|
|
11
|
+
*
|
|
12
|
+
* Deliberately narrow: only the singleton PUT runs effects. Widening it to create/update/delete would make
|
|
13
|
+
* every content write fail-able, which is the invariant the listener bus exists to protect.
|
|
14
|
+
*
|
|
15
|
+
* The DB row is already committed when effects run (better-sqlite3 writes are synchronous and CRUD holds
|
|
16
|
+
* no transaction), so a failing effect means "saved, but the side effect is stale" — never a rollback.
|
|
17
|
+
* The message an effect throws has to say so.
|
|
18
|
+
*/
|
|
19
|
+
export interface WriteEffectEvent {
|
|
20
|
+
def: CollectionDef
|
|
21
|
+
/** The row as saved. */
|
|
22
|
+
row: Record<string, unknown>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type WriteEffect = (event: WriteEffectEvent) => Promise<void> | void
|
|
26
|
+
|
|
27
|
+
const effects: WriteEffect[] = []
|
|
28
|
+
|
|
29
|
+
export function registerWriteEffect(fn: WriteEffect): void {
|
|
30
|
+
effects.push(fn)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function clearWriteEffects(): void {
|
|
34
|
+
effects.length = 0
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Run the registered effects in order. Rejects on the first failure, leaving the rest unrun. */
|
|
38
|
+
export async function runWriteEffects(def: CollectionDef, row: Record<string, unknown>): Promise<void> {
|
|
39
|
+
for (const fn of effects) await fn({ def, row })
|
|
40
|
+
}
|
|
@@ -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> = {}
|
|
@@ -51,22 +51,24 @@ export function ensureBuilt(c: CollectionDef | BuiltCollection): BuiltCollection
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
|
-
* Build the `applyConditions` hook
|
|
55
|
-
* when
|
|
56
|
-
* (it can't see siblings)
|
|
57
|
-
*
|
|
58
|
-
*
|
|
54
|
+
* Build the `applyConditions` hook — every pre-write check the per-field schema can't do — or `undefined`
|
|
55
|
+
* when the collection needs none (so CRUD skips it). Two sources, both of which need the whole record:
|
|
56
|
+
* the per-field schema relaxed every conditional field to optional (it can't see siblings), so `required`
|
|
57
|
+
* is re-enforced here for the ones whose condition is met; and the collection's own `def.validate`, for a
|
|
58
|
+
* rule spanning fields. Issues are keyed by the field's def name (so they map to the editor's per-field
|
|
59
|
+
* errors), resolving each value at its column key (`<name>Id` for single relation/media).
|
|
59
60
|
*/
|
|
60
61
|
function buildApplyConditions(def: CollectionDef): BuiltCollection['applyConditions'] {
|
|
61
62
|
const required = Object.entries(def.fields).filter(([, f]) => f.condition && f.required)
|
|
62
|
-
if (!required.length) return undefined
|
|
63
|
+
if (!required.length && !def.validate) return undefined
|
|
64
|
+
if (!required.length) return (record) => ({ issues: def.validate!(record) })
|
|
63
65
|
return (record) => {
|
|
64
66
|
const scope: Record<string, unknown> = {}
|
|
65
67
|
for (const [key, field] of Object.entries(def.fields)) scope[key] = record[resolveColumnName(key, field).jsKey]
|
|
66
68
|
const issues = required
|
|
67
69
|
.filter(([key, field]) => evaluateCondition(field.condition!, scope) && isEmptyValue(record[resolveColumnName(key, field).jsKey]))
|
|
68
70
|
.map(([key]) => ({ path: [key], message: 'This field is required.' }))
|
|
69
|
-
return { issues }
|
|
71
|
+
return { issues: [...issues, ...(def.validate?.(record) ?? [])] }
|
|
70
72
|
}
|
|
71
73
|
}
|
|
72
74
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { useBlurUp } from '../composables/useBlurUp'
|
|
3
3
|
import { useMediaVariant } from '../composables/useMediaVariant'
|
|
4
|
+
import { aiSourceTypeLabel } from '../utils/ai-disclosure'
|
|
4
5
|
import type { ResolvedMedia } from '../../server/utils/resolve'
|
|
5
6
|
import type { VariantFit, VariantFormat } from '../../../core/server/utils/kestrel-config'
|
|
6
7
|
|
|
@@ -17,6 +18,11 @@ const props = defineProps<{
|
|
|
17
18
|
formats?: VariantFormat[]
|
|
18
19
|
sizes?: string
|
|
19
20
|
priority?: boolean
|
|
21
|
+
/** Opt into a structurally-present but UNSTYLED EU AI Act disclosure badge (`.kestrel-img__ai-badge`),
|
|
22
|
+
* rendered only when `media.aiDisclosure` is set. Off by default: Kestrel must never publish a claim
|
|
23
|
+
* the consumer did not ask for, in a place/language/style it cannot know. The alternative escape hatch
|
|
24
|
+
* is to read `media.aiDisclosure` and render your own element — see docs/media-uploads.md. */
|
|
25
|
+
aiBadge?: boolean
|
|
20
26
|
}>()
|
|
21
27
|
|
|
22
28
|
const { model } = useMediaVariant(
|
|
@@ -44,6 +50,11 @@ const { imgEl, animate, placeholderStyle, onLoad } = useBlurUp(() => props.media
|
|
|
44
50
|
decoding="async"
|
|
45
51
|
@load="onLoad"
|
|
46
52
|
>
|
|
53
|
+
<span
|
|
54
|
+
v-if="aiBadge && media?.aiDisclosure"
|
|
55
|
+
class="kestrel-img__ai-badge"
|
|
56
|
+
:data-ai-source-type="media.aiDisclosure.sourceType"
|
|
57
|
+
>{{ media.aiDisclosure.note ?? aiSourceTypeLabel(media.aiDisclosure.sourceType) }}</span>
|
|
47
58
|
</picture>
|
|
48
59
|
</template>
|
|
49
60
|
|
|
@@ -63,3 +74,21 @@ const { imgEl, animate, placeholderStyle, onLoad } = useBlurUp(() => props.media
|
|
|
63
74
|
}
|
|
64
75
|
}
|
|
65
76
|
</style>
|
|
77
|
+
|
|
78
|
+
<!-- Deliberately UNSCOPED: a scoped rule carries the `[data-v-*]` attribute selector, which out-specifies
|
|
79
|
+
a consumer stylesheet targeting `.kestrel-img__ai-badge` and would make the badge un-restylable.
|
|
80
|
+
Layout only — no color, background, border, radius or font — so nothing is visible as a "badge" until
|
|
81
|
+
the consumer's own CSS says so. Scoped to pictures that actually contain one, so a consumer who never
|
|
82
|
+
sets `ai-badge` gets no rule at all. -->
|
|
83
|
+
<style>
|
|
84
|
+
picture:has(> .kestrel-img__ai-badge) {
|
|
85
|
+
position: relative;
|
|
86
|
+
display: inline-block;
|
|
87
|
+
}
|
|
88
|
+
.kestrel-img__ai-badge {
|
|
89
|
+
position: absolute;
|
|
90
|
+
inset-block-end: 0;
|
|
91
|
+
inset-inline-start: 0;
|
|
92
|
+
pointer-events: none;
|
|
93
|
+
}
|
|
94
|
+
</style>
|
|
@@ -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>
|
|
@@ -51,18 +51,20 @@ function onOpenItem(item: LibraryItem) {
|
|
|
51
51
|
viewerError.value = ''
|
|
52
52
|
viewerOpen.value = true
|
|
53
53
|
}
|
|
54
|
-
async function
|
|
54
|
+
async function onSaveMeta(alt: string, ai?: { aiSourceType: string | null; aiNote: string | null }) {
|
|
55
55
|
const f = viewerFile.value as LibraryFileRow | null
|
|
56
56
|
if (!f) return
|
|
57
57
|
viewerBusy.value = true
|
|
58
58
|
viewerError.value = ''
|
|
59
59
|
try {
|
|
60
60
|
// Alt lives in the per-locale translations JSON; write the primary locale (the library resolves
|
|
61
|
-
// alt in that same locale, so this round-trips into the list). The
|
|
62
|
-
//
|
|
61
|
+
// alt in that same locale, so this round-trips into the list). The AI disclosure is NOT per-locale,
|
|
62
|
+
// so it rides alongside as top-level keys — and only when the viewer actually offered it, so a save
|
|
63
|
+
// from a consumer with the feature off never clears a recorded disclosure. The precondition header
|
|
64
|
+
// lets the server 409 instead of silently losing a concurrent edit from another open viewer tab.
|
|
63
65
|
await $fetch(`/api/media/${f.id}`, {
|
|
64
66
|
method: 'PATCH',
|
|
65
|
-
body: { translations: { [primary]: { alt } } },
|
|
67
|
+
body: { translations: { [primary]: { alt } }, ...(ai ?? {}) },
|
|
66
68
|
...(f.updatedAt ? { headers: { 'x-kestrel-if-unmodified-since': String(new Date(f.updatedAt).getTime()) } } : {}),
|
|
67
69
|
})
|
|
68
70
|
} catch (e) {
|
|
@@ -291,8 +293,8 @@ const localizedMenu = computed(() => menuItems.value.map((s) => ({
|
|
|
291
293
|
<template>
|
|
292
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 -->
|
|
293
295
|
<section class="media-library" @click.self="lib.clear()" @dragenter="onDragEnter" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
|
|
294
|
-
<
|
|
295
|
-
<
|
|
296
|
+
<KestrelMediaPathBar :folder="folder" @navigate="lib.navigate" />
|
|
297
|
+
<KestrelMediaToolbar
|
|
296
298
|
:view="view"
|
|
297
299
|
:search="search"
|
|
298
300
|
:disabled="!!error"
|
|
@@ -301,23 +303,23 @@ const localizedMenu = computed(() => menuItems.value.map((s) => ({
|
|
|
301
303
|
@upload="onUpload"
|
|
302
304
|
@new-folder="newFolderOpen = true"
|
|
303
305
|
/>
|
|
304
|
-
<
|
|
306
|
+
<KestrelUiMenu :items="localizedMenu" @select="onMenuSelect">
|
|
305
307
|
<!-- @contextmenu.capture sets the menu target before Reka's own handler opens the menu, so menuItems is correct when the menu renders -->
|
|
306
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 -->
|
|
307
309
|
<div class="media-library__items" @contextmenu.capture="onContextMenu">
|
|
308
|
-
<
|
|
310
|
+
<KestrelUiAlert v-if="error" variant="error">{{ error }}</KestrelUiAlert>
|
|
309
311
|
<p v-else-if="!loading && !items.length" class="media-library__empty">{{ t('media.folderEmpty') }}</p>
|
|
310
|
-
<
|
|
311
|
-
<
|
|
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" />
|
|
312
314
|
</div>
|
|
313
|
-
</
|
|
315
|
+
</KestrelUiMenu>
|
|
314
316
|
<div v-if="hasMore" class="media-library__more">
|
|
315
|
-
<
|
|
317
|
+
<KestrelUiButton :disabled="loading" @click="lib.loadMore">{{ t('media.loadMore') }}</KestrelUiButton>
|
|
316
318
|
</div>
|
|
317
319
|
<p v-if="!clipboardEmpty" class="media-library__clipboard">
|
|
318
320
|
{{ clipboardCount }} {{ clipboardCount === 1 ? t('media.clipboardItem') : t('media.clipboardItems') }} {{ clipboard?.mode === 'cut' ? t('media.clipboardCut') : t('media.clipboardCopied') }}
|
|
319
|
-
<
|
|
320
|
-
<
|
|
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>
|
|
321
323
|
</p>
|
|
322
324
|
<p v-if="active || counts.done || counts.error || counts.skipped" class="media-library__status">
|
|
323
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>
|
|
@@ -325,20 +327,20 @@ const localizedMenu = computed(() => menuItems.value.map((s) => ({
|
|
|
325
327
|
<p class="media-library__sr-status" role="status" aria-live="polite">{{ srStatus }}</p>
|
|
326
328
|
<div v-if="pick" class="media-library__pickbar">
|
|
327
329
|
<span>{{ selectedFileIds.length }} {{ t('media.selected') }}</span>
|
|
328
|
-
<
|
|
329
|
-
<
|
|
330
|
+
<KestrelUiButton variant="primary" :disabled="!selectedFileIds.length" @click="onConfirmPick">{{ t('media.useSelected') }}</KestrelUiButton>
|
|
331
|
+
<KestrelUiButton variant="ghost" @click="emit('cancel')">{{ t('common.cancel') }}</KestrelUiButton>
|
|
330
332
|
</div>
|
|
331
|
-
<
|
|
333
|
+
<KestrelMediaUploadDialog
|
|
332
334
|
:conflicts="conflicts"
|
|
333
335
|
:open="conflicts.length > 0"
|
|
334
336
|
@resolve="upload.resolve"
|
|
335
337
|
@resolve-all="upload.resolveAll"
|
|
336
338
|
@update:open="(v) => { if (!v) upload.resolveAll('skip') }"
|
|
337
339
|
/>
|
|
338
|
-
<
|
|
339
|
-
<
|
|
340
|
-
<
|
|
341
|
-
<
|
|
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
|
|
342
344
|
:open="deleteOpen"
|
|
343
345
|
:report="deleteReport"
|
|
344
346
|
:names="fileNames"
|
|
@@ -347,7 +349,7 @@ const localizedMenu = computed(() => menuItems.value.map((s) => ({
|
|
|
347
349
|
@confirm="onConfirmDelete"
|
|
348
350
|
@update:open="(v) => { if (!v) { deleteOpen = false; deleteReport = null } }"
|
|
349
351
|
/>
|
|
350
|
-
<
|
|
352
|
+
<KestrelMediaRenameDialog
|
|
351
353
|
:open="renameOpen"
|
|
352
354
|
:name="renameName"
|
|
353
355
|
:busy="opsBusy"
|
|
@@ -355,8 +357,8 @@ const localizedMenu = computed(() => menuItems.value.map((s) => ({
|
|
|
355
357
|
@rename="onConfirmRename"
|
|
356
358
|
@update:open="(v) => { if (!v) { renameOpen = false; renameTarget = null } }"
|
|
357
359
|
/>
|
|
358
|
-
<
|
|
359
|
-
<
|
|
360
|
+
<KestrelUiAlert v-if="relocateError && !conflictOpen" variant="error">{{ relocateError }}</KestrelUiAlert>
|
|
361
|
+
<KestrelMediaConflictDialog
|
|
360
362
|
:open="conflictOpen"
|
|
361
363
|
:conflicts="pasteConflicts"
|
|
362
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>
|
|
@@ -27,7 +27,7 @@ function rename(c: UploadItem) {
|
|
|
27
27
|
</script>
|
|
28
28
|
|
|
29
29
|
<template>
|
|
30
|
-
<
|
|
30
|
+
<KestrelUiDialog
|
|
31
31
|
:open="open"
|
|
32
32
|
:title="t('media.conflicts.title')"
|
|
33
33
|
:description="t('media.conflicts.desc', { count: conflicts.length })"
|
|
@@ -35,22 +35,22 @@ function rename(c: UploadItem) {
|
|
|
35
35
|
>
|
|
36
36
|
<div class="media-conflicts__all">
|
|
37
37
|
<span>{{ t('media.conflicts.applyAll') }}</span>
|
|
38
|
-
<
|
|
39
|
-
<
|
|
40
|
-
<
|
|
38
|
+
<KestrelUiButton @click="emit('resolve-all', 'overwrite')">{{ t('media.conflicts.overwriteAll') }}</KestrelUiButton>
|
|
39
|
+
<KestrelUiButton @click="emit('resolve-all', 'rename')">{{ t('media.conflicts.renameAll') }}</KestrelUiButton>
|
|
40
|
+
<KestrelUiButton @click="emit('resolve-all', 'skip')">{{ t('media.conflicts.skipAll') }}</KestrelUiButton>
|
|
41
41
|
</div>
|
|
42
42
|
<ul class="media-conflicts__list">
|
|
43
43
|
<li v-for="c in conflicts" :key="c.id" class="media-conflicts__row">
|
|
44
44
|
<span class="media-conflicts__name">{{ c.filename }}</span>
|
|
45
|
-
<
|
|
45
|
+
<KestrelUiTextInput v-model="names[c.id]" :aria-label="t('media.conflicts.newName', { name: c.filename })" />
|
|
46
46
|
<div class="media-conflicts__actions">
|
|
47
|
-
<
|
|
48
|
-
<
|
|
49
|
-
<
|
|
47
|
+
<KestrelUiButton @click="emit('resolve', c.id, 'overwrite', undefined)">{{ t('media.conflicts.overwrite') }}</KestrelUiButton>
|
|
48
|
+
<KestrelUiButton variant="primary" @click="rename(c)">{{ t('media.conflicts.rename') }}</KestrelUiButton>
|
|
49
|
+
<KestrelUiButton @click="emit('resolve', c.id, 'skip', undefined)">{{ t('media.conflicts.skip') }}</KestrelUiButton>
|
|
50
50
|
</div>
|
|
51
51
|
</li>
|
|
52
52
|
</ul>
|
|
53
|
-
</
|
|
53
|
+
</KestrelUiDialog>
|
|
54
54
|
</template>
|
|
55
55
|
|
|
56
56
|
<style lang="scss" scoped>
|