feat(SPA): media field type

This commit is contained in:
Florian Sylvain committed 2026-10-21 15:17:15 +02:00
1 parent a2d596f4ca
commit 3e7fc9a4a5
57 files changed
+6109 -75

No files matched your search

+56 -6
View File
@@ -8,6 +8,7 @@ import VariantPickerField from '@/components/variants/VariantPickerField.vue'
import type { LibraryStatus } from '@/composables/useLibraryList'
import type { ImageVariant } from '@/composables/useImageVariants'
import type { MediaItem } from '@/composables/useMedia'
import type { FieldPick } from '@/lib/imageChoice'
import { accessibleName, formatDimensions, formatSize } from '@/lib/mediaFormat'
const SEARCH_DEBOUNCE_MS = 120
@@ -41,11 +42,14 @@ const props = defineProps<{
plain?: boolean
tileRatio?: 'square' | 'landscape'
hint?: string
field?: FieldPick
noneChosen?: boolean
}>()
const emit = defineEmits<{
search: [query: string]
select: [item: MediaItem]
'select-none': []
'load-more': []
retry: []
upload: []
@@ -71,12 +75,19 @@ const searching = computed(() => props.query !== '')
const summary = computed(() =>
searching.value ? t('Newest first') : t('Newest first · {0} images', [props.total]),
)
const chosen = computed(() => props.selected !== null || props.noneChosen)
const scrollHeight = computed(() => {
if (props.plain) {
return 'max-h-71 md:max-h-89'
}
return props.selected ? 'max-h-32 md:max-h-49.5' : 'max-h-77'
return chosen.value ? 'max-h-32 md:max-h-49.5' : 'max-h-77'
})
const showNoImage = computed(() => props.field?.optional === true && showGrid.value)
const emptyDescription = computed(() =>
props.field
? t('Images you upload appear here, ready to use in any article or entry.')
: t('Images you upload appear here, ready to use in any article.'),
)
const showEmptyLibrary = computed(
() => props.status === 'ready' && props.items.length === 0 && !searching.value,
)
@@ -85,7 +96,7 @@ const showNoResults = computed(
)
const showGrid = computed(() => props.status === 'ready' && props.items.length > 0)
const showLoadMore = computed(
() => showGrid.value && props.hasMore && (props.plain || !props.selected),
() => showGrid.value && props.hasMore && (props.plain || !chosen.value),
)
const noResultsTitle = computed(() => t('No images match \u0022{0}\u0022', [props.query]))
@@ -115,7 +126,19 @@ function isSelected(item: MediaItem) {
}
function isInArticle(item: MediaItem) {
return props.inArticleIds.includes(item.id)
return !props.field && props.inArticleIds.includes(item.id)
}
function isCurrent(item: MediaItem) {
return props.field?.currentId === item.id
}
function tileName(item: MediaItem) {
if (!props.field) {
return accessibleName(item)
}
const name = `${item.filename}, ${formatSize(item.size)}`
return isCurrent(item) ? `${name}, ${t('Current')}` : name
}
function onTileKeydown(event: KeyboardEvent, item: MediaItem) {
@@ -206,7 +229,7 @@ onBeforeUnmount(clearDebounce)
variant="naked"
icon="i-lucide-images"
:title="t('Your library is empty')"
:description="t('Images you upload appear here, ready to use in any article.')"
:description="emptyDescription"
:ui="EMPTY_STATE_UI"
>
<template #actions>
@@ -253,6 +276,26 @@ onBeforeUnmount(clearDebounce)
</div>
<template v-else-if="showGrid">
<button
v-if="showNoImage"
type="button"
data-testid="picker-no-image"
:aria-pressed="noneChosen"
class="flex min-h-10 items-center gap-2.5 border px-3 text-start focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary max-md:min-h-12"
:class="
noneChosen
? 'border-primary bg-(--rc-sand) text-(--rc-on-sand)'
: 'border-accented bg-default text-highlighted'
"
@click="emit('select-none')"
>
<UIcon name="i-lucide-image-off" class="size-4 shrink-0" />
<span class="font-medium">{{ t('No image') }}</span>
<span class="flex-1" :class="noneChosen ? 'text-(--rc-on-sand)' : 'text-muted'">{{
t('Leave this field empty.')
}}</span>
<UIcon v-if="noneChosen" name="i-lucide-check" class="size-4 shrink-0" />
</button>
<span v-if="!plain" data-testid="picker-summary" class="text-xs/5 text-muted">{{
summary
}}</span>
@@ -268,7 +311,7 @@ onBeforeUnmount(clearDebounce)
type="button"
data-picker-tile
:data-image-id="item.id"
:aria-label="accessibleName(item)"
:aria-label="tileName(item)"
:aria-pressed="isSelected(item)"
class="group flex min-w-0 flex-col gap-1.5 text-start focus-visible:outline-none"
@click="emit('select', item)"
@@ -287,6 +330,11 @@ onBeforeUnmount(clearDebounce)
class="w-full border border-default"
:class="tileAspect"
/>
<span
v-if="isCurrent(item)"
class="absolute bottom-1 left-1 inline-flex h-4.5 items-center bg-(--rc-sand) px-1.25 text-[0.6875rem] font-medium text-(--rc-on-sand)"
>{{ t('Current') }}</span
>
<span
v-if="isInArticle(item)"
class="absolute bottom-1 left-1 inline-flex h-4.5 items-center bg-secondary-200 px-1.25 text-[0.6875rem] font-medium text-primary-900 dark:bg-secondary-300"
@@ -338,7 +386,7 @@ onBeforeUnmount(clearDebounce)
<p v-if="hint" class="text-xs/5 text-muted">{{ hint }}</p>
<div
v-if="selected && !plain"
v-if="selected && !plain && !noneChosen"
data-testid="picker-panel"
class="flex flex-col gap-3 border-t border-default pt-3"
>
@@ -355,12 +403,14 @@ onBeforeUnmount(clearDebounce)
</div>
</div>
<VariantPickerField
v-if="!field"
v-model:variant="variant"
:image="selected"
:variants="variants ?? []"
:ready="variantsReady ?? false"
/>
<UFormField
v-if="!field"
:label="t('Alt text (optional)')"
:help="
t(