Files
RenewCMS/web/src/components/MediaPicker.vue
T

345 lines
9.8 KiB
Vue

<script setup lang="ts">
import { t } from '@/i18n'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import MediaThumb from '@/components/MediaThumb.vue'
import type { LibraryStatus } from '@/composables/useLibraryList'
import type { MediaItem } from '@/composables/useMedia'
import { accessibleName, formatDimensions, formatSize } from '@/lib/mediaFormat'
const SEARCH_DEBOUNCE_MS = 120
const DESKTOP_QUERY = '(min-width: 48rem)'
const FIRST_LOAD_SKELETONS_DESKTOP = 8
const FIRST_LOAD_SKELETONS_MOBILE = 9
const COLUMNS_DESKTOP = 4
const COLUMNS_MOBILE = 3
const SEARCH_ICON_SIZE = 'size-3.5'
const EMPTY_STATE_UI = {
root: 'gap-3 border border-default bg-default px-6 py-8',
header: 'gap-3',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-[0.9375rem] font-semibold',
description: 'max-w-80 text-muted text-pretty',
}
const props = defineProps<{
items: MediaItem[]
status: LibraryStatus
showSkeleton: boolean
loadingMore: boolean
hasMore: boolean
total: number
query: string
firstNewId: number | null
inArticleIds: number[]
selected: MediaItem | null
}>()
const emit = defineEmits<{
search: [query: string]
select: [item: MediaItem]
'load-more': []
retry: []
upload: []
}>()
const alt = defineModel<string>('alt', { default: '' })
const isDesktop = useMediaQuery(DESKTOP_QUERY)
const grid = ref<HTMLElement | null>(null)
const searchText = ref(props.query)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
const columns = computed(() => (isDesktop.value ? COLUMNS_DESKTOP : COLUMNS_MOBILE))
const firstLoadSkeletons = computed(() =>
isDesktop.value ? FIRST_LOAD_SKELETONS_DESKTOP : FIRST_LOAD_SKELETONS_MOBILE,
)
const searching = computed(() => props.query !== '')
const summary = computed(() =>
searching.value ? t('Newest first') : t('Newest first · {0} images', [props.total]),
)
const scrollHeight = computed(() => (props.selected ? 'max-h-32 md:max-h-49.5' : 'max-h-77'))
const showEmptyLibrary = computed(
() => props.status === 'ready' && props.items.length === 0 && !searching.value,
)
const showNoResults = computed(
() => props.status === 'ready' && props.items.length === 0 && searching.value,
)
const showGrid = computed(() => props.status === 'ready' && props.items.length > 0)
const showLoadMore = computed(() => showGrid.value && props.hasMore && !props.selected)
const noResultsTitle = computed(() => t('No images match \u0022{0}\u0022', [props.query]))
function clearDebounce() {
if (debounceTimer !== null) {
clearTimeout(debounceTimer)
debounceTimer = null
}
}
watch(searchText, (value) => {
clearDebounce()
debounceTimer = setTimeout(() => {
debounceTimer = null
emit('search', value.trim())
}, SEARCH_DEBOUNCE_MS)
})
function clearSearch() {
clearDebounce()
searchText.value = ''
emit('search', '')
}
function isSelected(item: MediaItem) {
return props.selected?.id === item.id
}
function isInArticle(item: MediaItem) {
return props.inArticleIds.includes(item.id)
}
function onTileKeydown(event: KeyboardEvent, item: MediaItem) {
if (event.key !== 'Enter' && event.key !== ' ') {
return
}
event.preventDefault()
if (!event.repeat) {
emit('select', item)
}
}
function onTileKeyup(event: KeyboardEvent) {
if (event.key === ' ') {
event.preventDefault()
}
}
watch(
() => props.firstNewId,
async (id) => {
if (id === null) {
return
}
await nextTick()
grid.value?.querySelector<HTMLElement>(`[data-image-id="${id}"]`)?.focus()
},
)
onBeforeUnmount(clearDebounce)
</script>
<template>
<div class="flex min-h-0 flex-col gap-3">
<UInput
v-model="searchText"
type="text"
icon="i-lucide-search"
:placeholder="t('Search by filename')"
:aria-label="t('Search by filename')"
class="w-full"
:ui="{
base: 'h-11 text-lg md:h-8 md:text-sm',
leadingIcon: SEARCH_ICON_SIZE,
}"
>
<template v-if="searchText" #trailing>
<UButton
color="neutral"
variant="link"
size="xs"
icon="i-lucide-x"
:aria-label="t('Clear search')"
class="text-muted"
@click="clearSearch"
/>
</template>
</UInput>
<UAlert
v-if="status === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load your library')"
:description="t('Check your connection and try again.')"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
icon="i-lucide-rotate-cw"
class="h-11 gap-1.5 border border-accented bg-default px-2.5 text-xs text-highlighted ring-0 md:h-7"
:ui="{ leadingIcon: 'size-3.5' }"
@click="emit('retry')"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<UEmpty
v-else-if="showEmptyLibrary"
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.')"
:ui="EMPTY_STATE_UI"
>
<template #actions>
<UButton icon="i-lucide-upload" class="h-11 px-4 md:h-8" @click="emit('upload')">
{{ t('Upload an image') }}
</UButton>
</template>
</UEmpty>
<UEmpty
v-else-if="showNoResults"
variant="naked"
icon="i-lucide-search"
:title="noResultsTitle"
:description="t('Check the spelling, or search for a shorter part of the filename.')"
:ui="EMPTY_STATE_UI"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
class="h-11 border border-accented bg-default px-4 text-highlighted ring-0 md:h-8"
@click="clearSearch"
>
{{ t('Clear search') }}
</UButton>
</template>
</UEmpty>
<div
v-else-if="status === 'loading' && showSkeleton"
aria-busy="true"
class="grid grid-cols-3 gap-x-2 gap-y-3 md:grid-cols-4 md:gap-x-3"
>
<div
v-for="n in firstLoadSkeletons"
:key="n"
data-testid="picker-skeleton-tile"
class="flex flex-col gap-1.5"
>
<USkeleton class="aspect-square w-full rounded-none bg-accented" />
<USkeleton class="h-2.5 w-[70%] rounded-none bg-accented" />
</div>
</div>
<template v-else-if="showGrid">
<span data-testid="picker-summary" class="text-xs/5 text-muted">{{ summary }}</span>
<div class="-m-1 min-h-0 overflow-y-auto p-1" :class="scrollHeight">
<div ref="grid" class="grid grid-cols-3 gap-x-2 gap-y-3 md:grid-cols-4 md:gap-x-3">
<button
v-for="item in items"
:key="item.id"
type="button"
data-picker-tile
:data-image-id="item.id"
:aria-label="accessibleName(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)"
@keydown="onTileKeydown($event, item)"
@keyup="onTileKeyup"
>
<span
class="relative block w-full group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-primary"
:class="
isSelected(item) ? 'outline-2 outline-offset-2 outline-primary' : ''
"
>
<MediaThumb
:item="item"
size="md"
class="aspect-square w-full border border-default"
/>
<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"
>{{ t('In this article') }}</span
>
<span
v-if="isSelected(item)"
data-testid="picker-check"
class="absolute top-1 right-1 flex size-5 items-center justify-center bg-primary text-inverted"
>
<UIcon name="i-lucide-check" class="size-3.5" />
</span>
</span>
<span class="truncate text-xs/5 text-toned">{{ item.filename }}</span>
</button>
<template v-if="loadingMore && showSkeleton">
<div
v-for="n in columns"
:key="`skeleton-${n}`"
data-testid="picker-skeleton-tile"
class="flex flex-col gap-1.5"
>
<USkeleton class="aspect-square w-full rounded-none bg-accented" />
<USkeleton class="h-2.5 w-[70%] rounded-none bg-accented" />
</div>
</template>
</div>
</div>
<div v-if="showLoadMore" class="flex justify-center">
<UButton
color="neutral"
variant="outline"
:icon="loadingMore ? 'i-lucide-loader-circle' : undefined"
:aria-disabled="loadingMore || undefined"
class="h-11 border border-accented bg-default px-3 text-highlighted ring-0 aria-disabled:opacity-40 md:h-8"
:ui="{
leadingIcon: loadingMore ? 'size-4 animate-spin' : 'size-4',
}"
@click="emit('load-more')"
>
{{ loadingMore ? t('Loading…') : t('Load more') }}
</UButton>
</div>
</template>
<div
v-if="selected"
data-testid="picker-panel"
class="flex flex-col gap-3 border-t border-default pt-3"
>
<div class="flex items-center gap-3">
<MediaThumb :item="selected" size="sm" class="size-14 border border-default" />
<div class="flex min-w-0 flex-col">
<span class="truncate font-medium text-highlighted">{{
selected.filename
}}</span>
<span class="text-xs/5 text-muted"
>{{ formatDimensions(selected.width, selected.height) }} ·
{{ formatSize(selected.size) }}</span
>
</div>
</div>
<UFormField
:label="t('Alt text (optional)')"
help="Describes the image for people who use a screen reader, and shows if it doesn't load. Leave it empty if the image is only decoration."
:ui="{
label: 'font-medium text-highlighted',
help: 'text-xs/5 text-muted',
}"
>
<UInput
v-model="alt"
type="text"
:placeholder="t('e.g. North wall of the kitchen before plastering')"
class="w-full"
:ui="{ base: 'h-11 text-lg md:h-10 md:text-sm' }"
/>
</UFormField>
</div>
</div>
</template>