229 lines
5.4 KiB
Vue
229 lines
5.4 KiB
Vue
<script setup lang="ts">
|
|
import { t, formatNumber } from '@/i18n'
|
|
|
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
|
import type { MediaCounts } from '@/composables/useMedia'
|
|
import type { MediaControls, MediaFilter, MediaSort, MediaView } from '@/lib/mediaQuery'
|
|
import { UNDERLINE_TAB_TRIGGER } from '@/lib/tabStyles'
|
|
|
|
const SEARCH_DEBOUNCE_MS = 120
|
|
|
|
const SORT_LABELS: Record<MediaSort, string> = {
|
|
get newest() {
|
|
return t('Newest')
|
|
},
|
|
get oldest() {
|
|
return t('Oldest')
|
|
},
|
|
get name() {
|
|
return t('Name')
|
|
},
|
|
get largest() {
|
|
return t('Largest')
|
|
},
|
|
}
|
|
|
|
const VIEW_OPTIONS: { value: MediaView; icon: string; label: string }[] = [
|
|
{
|
|
value: 'grid',
|
|
icon: 'i-lucide-layout-grid',
|
|
get label() {
|
|
return t('Grid view')
|
|
},
|
|
},
|
|
{
|
|
value: 'list',
|
|
icon: 'i-lucide-list',
|
|
get label() {
|
|
return t('List view')
|
|
},
|
|
},
|
|
]
|
|
|
|
const props = defineProps<{
|
|
controls: MediaControls
|
|
counts: MediaCounts | null
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
'update:q': [value: string]
|
|
'update:filter': [value: MediaFilter]
|
|
'update:sort': [value: MediaSort]
|
|
'update:view': [value: MediaView]
|
|
}>()
|
|
|
|
const searchText = ref(props.controls.q)
|
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
function clearDebounce() {
|
|
if (debounceTimer !== null) {
|
|
clearTimeout(debounceTimer)
|
|
debounceTimer = null
|
|
}
|
|
}
|
|
|
|
watch(
|
|
() => props.controls.q,
|
|
(value) => {
|
|
if (value !== searchText.value.trim()) {
|
|
clearDebounce()
|
|
searchText.value = value
|
|
}
|
|
},
|
|
)
|
|
|
|
watch(searchText, (value) => {
|
|
clearDebounce()
|
|
debounceTimer = setTimeout(() => {
|
|
debounceTimer = null
|
|
const trimmed = value.trim()
|
|
if (trimmed !== props.controls.q) {
|
|
emit('update:q', trimmed)
|
|
}
|
|
}, SEARCH_DEBOUNCE_MS)
|
|
})
|
|
|
|
function clearSearch() {
|
|
searchText.value = ''
|
|
}
|
|
|
|
onBeforeUnmount(clearDebounce)
|
|
|
|
const tabItems = computed(() => [
|
|
{
|
|
get label() {
|
|
return t('All')
|
|
},
|
|
value: 'all',
|
|
count: props.counts?.all,
|
|
},
|
|
{
|
|
get label() {
|
|
return t('Unused')
|
|
},
|
|
value: 'unused',
|
|
count: props.counts?.unused,
|
|
},
|
|
{
|
|
get label() {
|
|
return t('Duplicates')
|
|
},
|
|
value: 'duplicates',
|
|
count: props.counts?.duplicates,
|
|
},
|
|
])
|
|
|
|
const tab = computed({
|
|
get: () => props.controls.filter,
|
|
set: (value: string) => {
|
|
if (value === 'all' || value === 'unused' || value === 'duplicates') {
|
|
emit('update:filter', value)
|
|
}
|
|
},
|
|
})
|
|
|
|
const sortItems = computed(() =>
|
|
(Object.keys(SORT_LABELS) as MediaSort[]).map((value) => ({
|
|
label: SORT_LABELS[value],
|
|
type: 'checkbox' as const,
|
|
checked: props.controls.sort === value,
|
|
onUpdateChecked: () => emit('update:sort', value),
|
|
})),
|
|
)
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
class="flex flex-col gap-3 md:flex-row md:items-end md:gap-3 md:border-b md:border-default"
|
|
>
|
|
<UTabs
|
|
v-model="tab"
|
|
:items="tabItems"
|
|
variant="link"
|
|
color="neutral"
|
|
:content="false"
|
|
class="order-2 md:order-none md:-mb-px md:flex-1"
|
|
:ui="{
|
|
list: 'gap-5 border-b-0 p-0 max-md:border-b',
|
|
trigger: UNDERLINE_TAB_TRIGGER,
|
|
}"
|
|
>
|
|
<template #default="{ item }">
|
|
<span class="flex items-center gap-1.5">
|
|
<span>{{ item.label }}</span>
|
|
<span
|
|
v-if="item.value === 'duplicates' && item.count"
|
|
data-testid="duplicates-count"
|
|
class="inline-flex h-4.5 min-w-4.5 items-center justify-center bg-(--rc-info-soft) px-1.25 font-mono text-[0.6875rem] font-medium text-info"
|
|
>{{ item.count === undefined ? undefined : formatNumber(item.count) }}</span
|
|
>
|
|
<span
|
|
v-else-if="item.value !== 'duplicates' && item.count !== undefined"
|
|
class="font-mono text-[0.6875rem] font-medium text-muted"
|
|
>{{ item.count === undefined ? undefined : formatNumber(item.count) }}</span
|
|
>
|
|
</span>
|
|
</template>
|
|
</UTabs>
|
|
|
|
<UInput
|
|
v-model="searchText"
|
|
type="text"
|
|
icon="i-lucide-search"
|
|
:placeholder="t('Search by filename')"
|
|
:aria-label="t('Search by filename')"
|
|
class="order-1 w-full md:order-none md:mb-1.5 md:w-60"
|
|
:ui="{
|
|
base: 'h-11 text-lg md:h-8 md:text-sm',
|
|
leadingIcon: 'size-3.5',
|
|
}"
|
|
>
|
|
<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>
|
|
|
|
<div class="order-3 flex gap-2 md:order-none md:mb-1.5">
|
|
<UDropdownMenu :items="sortItems" :content="{ align: 'start' }">
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
trailing-icon="i-lucide-chevron-down"
|
|
:aria-label="t('Sort')"
|
|
class="h-11 flex-1 justify-between border border-accented bg-default px-2.5 text-sm font-normal text-highlighted ring-0 md:h-8 md:w-32 md:flex-none"
|
|
:ui="{ trailingIcon: 'size-3.5 text-muted' }"
|
|
>
|
|
{{ SORT_LABELS[controls.sort] }}
|
|
</UButton>
|
|
</UDropdownMenu>
|
|
|
|
<div role="group" :aria-label="t('View')" class="flex shrink-0 border border-accented">
|
|
<UButton
|
|
v-for="option in VIEW_OPTIONS"
|
|
:key="option.value"
|
|
color="neutral"
|
|
variant="ghost"
|
|
:icon="option.icon"
|
|
:aria-label="option.label"
|
|
:aria-pressed="controls.view === option.value"
|
|
class="h-10.5 w-11 justify-center md:h-7.5 md:w-8"
|
|
:class="
|
|
controls.view === option.value
|
|
? 'bg-secondary-200 text-primary-900 hover:bg-secondary-200 dark:bg-secondary-300 dark:hover:bg-secondary-300'
|
|
: 'bg-default text-toned'
|
|
"
|
|
@click="emit('update:view', option.value)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|