345 lines
9.8 KiB
Vue
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>
|