feat(SPA): media page T0

This commit is contained in:
fsylvain committed 2026-09-30 19:45:47 +02:00
1 parent 0d99544579
commit 67d456baf6
32 files changed
+4874 -40

No files matched your search

+187
View File
@@ -0,0 +1,187 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import type { MediaCounts } from '@/composables/useMedia'
import type { MediaControls, MediaFilter, MediaSort, MediaView } from '@/lib/mediaQuery'
const SEARCH_DEBOUNCE_MS = 120
const TAB_TRIGGER =
'h-9 px-0 py-0 data-[state=inactive]:text-toned in-[[data-slot=list]:not(:has([data-slot=indicator]))]:data-[state=active]:after:bottom-0 in-[[data-slot=list]:not(:has([data-slot=indicator]))]:data-[state=active]:after:h-0.5 max-md:h-11 max-md:text-[0.9375rem]'
const SORT_LABELS: Record<MediaSort, string> = {
newest: 'Newest',
oldest: 'Oldest',
name: 'Name',
largest: 'Largest',
}
const VIEW_OPTIONS: { value: MediaView; icon: string; label: string }[] = [
{ value: 'grid', icon: 'i-lucide-layout-grid', label: 'Grid view' },
{ value: 'list', icon: 'i-lucide-list', label: '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(() => [
{ label: 'All', value: 'all', count: props.counts?.all },
{ label: 'Unused', value: 'unused', count: props.counts?.unused },
{ label: 'Duplicates', value: 'duplicates', count: props.counts?.duplicates, disabled: true },
])
const tab = computed({
get: () => props.controls.filter,
set: (value: string) => {
if (value === 'all' || value === 'unused') {
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: 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 }}</span
>
<span
v-else-if="item.value !== 'duplicates' && item.count !== undefined"
class="font-mono text-[0.6875rem] font-medium text-muted"
>{{ item.count }}</span
>
</span>
</template>
</UTabs>
<UInput
v-model="searchText"
type="text"
icon="i-lucide-search"
placeholder="Search by filename"
aria-label="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="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="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="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>