feat: media picker and image removal
This commit is contained in:
1 parent
008b0dfbb9
commit
b3793d5dbb
54 files changed
+3769
-1412
No files matched your search
@@ -0,0 +1,337 @@
|
||||
<script setup lang="ts">
|
||||
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 ? 'Newest first' : `Newest first · ${props.total} images`,
|
||||
)
|
||||
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(() => `No images match "${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="Search by filename"
|
||||
aria-label="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="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="Couldn't load your library"
|
||||
description="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')"
|
||||
>
|
||||
Try again
|
||||
</UButton>
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<UEmpty
|
||||
v-else-if="showEmptyLibrary"
|
||||
variant="naked"
|
||||
icon="i-lucide-images"
|
||||
title="Your library is empty"
|
||||
description="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')">
|
||||
Upload an image
|
||||
</UButton>
|
||||
</template>
|
||||
</UEmpty>
|
||||
|
||||
<UEmpty
|
||||
v-else-if="showNoResults"
|
||||
variant="naked"
|
||||
icon="i-lucide-search"
|
||||
:title="noResultsTitle"
|
||||
description="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"
|
||||
>
|
||||
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"
|
||||
>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 ? 'Loading…' : '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="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="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>
|
||||
Reference in new issue
Block a user