Files
BRETT/app/components/UploadFilters.vue
2026-10-03 23:45:04 +02:00

262 lines
10 KiB
Vue

<script setup lang="ts">
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { cn } from '@/lib/utils'
interface ListedUser {
id: number
name: string
color: string | null
avatarUrl: string | null
stats?: { uploads: number }
}
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const TYPE_OPTIONS = [
{ value: 'image', labelKey: 'filters.images', icon: 'ph:image-fill' },
{ value: 'video', labelKey: 'filters.videos', icon: 'ph:video-camera-fill' },
{ value: 'audio', labelKey: 'filters.audio', icon: 'ph:music-note-fill' },
{ value: 'youtube', labelKey: null, icon: 'simple-icons:youtube' },
{ value: 'twitch', labelKey: null, icon: 'simple-icons:twitch' },
{ value: 'link', labelKey: 'filters.links', icon: 'ph:link-fill' },
] as const
const DAY_OPTIONS = [
{ value: '1', labelKey: 'filters.today' },
{ value: '7', labelKey: 'filters.days7' },
{ value: '30', labelKey: 'filters.days30' },
{ value: '365', labelKey: 'filters.lastYear' },
] as const
const open = ref(false)
const uploaderOpen = ref(false)
const users = ref<ListedUser[]>([])
const usersLoaded = ref(false)
async function loadUsers() {
if (usersLoaded.value) return
usersLoaded.value = true
try {
users.value = await $fetch<ListedUser[]>('/api/users')
}
catch {
users.value = []
}
}
watch(open, v => v && loadUsers())
onMounted(() => {
if (route.query.uploader) loadUsers()
})
// Für die Uploader-Auswahl nur Nutzer mit Uploads anzeigen
const uploaders = computed(() => users.value.filter(u => !u.stats || u.stats.uploads > 0))
const tagFilter = computed(() => typeof route.query.tag === 'string' ? route.query.tag : undefined)
const selectedTypes = computed<string[]>({
get: () => typeof route.query.type === 'string' ? route.query.type.split(',').filter(Boolean) : [],
set: v => setQuery({ type: v.length ? v.join(',') : undefined }),
})
const uploaderId = computed(() => typeof route.query.uploader === 'string' ? route.query.uploader : '')
const selectedUser = computed(() => users.value.find(u => String(u.id) === uploaderId.value))
const daysModel = computed<string>({
get: () => typeof route.query.days === 'string' && route.query.days ? route.query.days : 'all',
set: v => setQuery({ days: v === 'all' ? undefined : v }),
})
const daysOption = computed(() => DAY_OPTIONS.find(d => d.value === daysModel.value))
const typeLabel = (value: string) => {
const option = TYPE_OPTIONS.find(o => o.value === value)
return option ? (option.labelKey ? t(option.labelKey) : option.value) : value
}
const typeIcon = (value: string) => TYPE_OPTIONS.find(t => t.value === value)?.icon ?? 'ph:image-fill'
const activeCount = computed(() =>
(tagFilter.value ? 1 : 0) + selectedTypes.value.length + (uploaderId.value ? 1 : 0) + (daysOption.value ? 1 : 0),
)
function setQuery(patch: Record<string, string | undefined>) {
const query = { ...route.query }
for (const [key, value] of Object.entries(patch)) {
if (value) query[key] = value
else delete query[key]
}
router.replace({ query })
}
function selectUser(u: ListedUser | null) {
setQuery({ uploader: u ? String(u.id) : undefined })
uploaderOpen.value = false
}
function clearFilters() {
setQuery({ tag: undefined, type: undefined, uploader: undefined, days: undefined })
}
</script>
<template>
<div class="flex flex-wrap items-center gap-2">
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button variant="outline" size="sm" class="gap-1.5">
<Icon name="ph:sliders-horizontal-fill" class="size-4" />
{{ $t('filters.filter') }}
<Badge v-if="activeCount" variant="secondary" class="ml-0.5 px-1.5 text-xs">{{ activeCount }}</Badge>
</Button>
</PopoverTrigger>
<PopoverContent class="w-72" align="start">
<FieldGroup class="gap-4">
<Field>
<FieldLabel>{{ $t('filters.type') }}</FieldLabel>
<ToggleGroup v-model="selectedTypes" type="multiple" variant="outline" :spacing="2" class="flex-wrap justify-start">
<ToggleGroupItem
v-for="o in TYPE_OPTIONS"
:key="o.value"
:value="o.value"
:aria-label="typeLabel(o.value)"
class="gap-1.5 rounded-md px-2.5"
>
<Icon :name="o.icon" class="size-4" />
{{ typeLabel(o.value) }}
</ToggleGroupItem>
</ToggleGroup>
</Field>
<Field>
<FieldLabel>{{ $t('filters.uploader') }}</FieldLabel>
<Popover v-model:open="uploaderOpen">
<PopoverTrigger as-child>
<Button variant="outline" role="combobox" :aria-expanded="uploaderOpen" class="w-full justify-between font-normal">
<span class="flex items-center gap-1.5 truncate">
<Icon name="ph:user-fill" class="size-4 shrink-0 text-muted-foreground" />
<span :style="selectedUser?.color ? { color: selectedUser.color } : undefined" class="truncate">
{{ selectedUser?.name ?? $t('filters.allUploaders') }}
</span>
</span>
<Icon name="ph:caret-up-down-fill" class="size-4 shrink-0 text-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-64 p-0">
<Command>
<CommandInput :placeholder="$t('filters.searchUser')" />
<CommandList>
<CommandEmpty>{{ $t('filters.noUser') }}</CommandEmpty>
<CommandGroup>
<CommandItem value="alle" class="gap-2" @select="selectUser(null)">
<Icon name="ph:check-fill" :class="cn('size-4', !uploaderId ? 'opacity-100' : 'opacity-0')" />
{{ $t('filters.allUploaders') }}
</CommandItem>
<CommandItem
v-for="u in uploaders"
:key="u.id"
:value="u.name"
class="gap-2"
@select="selectUser(u)"
>
<Icon name="ph:check-fill" :class="cn('size-4', String(u.id) === uploaderId ? 'opacity-100' : 'opacity-0')" />
<NuxtImg
v-if="u.avatarUrl"
:src="u.avatarUrl"
:alt="u.name"
width="20"
height="20"
fit="cover"
class="size-5 rounded-sm"
/>
<span
v-else
class="flex size-5 items-center justify-center rounded-sm text-[10px] font-medium"
:style="u.color ? { backgroundColor: u.color, color: '#fff' } : { backgroundColor: 'var(--color-muted)' }"
>{{ u.name.slice(0, 2).toUpperCase() }}</span>
<span :style="u.color ? { color: u.color } : undefined">{{ u.name }}</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</Field>
<Field>
<FieldLabel>{{ $t('filters.period') }}</FieldLabel>
<Select v-model="daysModel">
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('filters.allTime')" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{{ $t('filters.allTime') }}</SelectItem>
<SelectItem v-for="d in DAY_OPTIONS" :key="d.value" :value="d.value">{{ $t(d.labelKey) }}</SelectItem>
</SelectContent>
</Select>
</Field>
<Button variant="ghost" size="sm" :disabled="!activeCount" class="self-start" @click="clearFilters">
{{ $t('common.reset') }}
</Button>
</FieldGroup>
</PopoverContent>
</Popover>
<span v-if="tagFilter" class="inline-flex items-center gap-1.5 rounded-sm bg-muted px-2.5 py-1 text-sm">
<Icon name="ph:tag-fill" class="size-3.5" />
{{ tagFilter }}
<button
type="button"
class="text-muted-foreground transition-colors hover:text-foreground"
:aria-label="$t('filters.removeTag')"
@click="setQuery({ tag: undefined })"
>
<Icon name="ph:x-fill" class="size-3.5" />
</button>
</span>
<span v-for="t in selectedTypes" :key="t" class="inline-flex items-center gap-1.5 rounded-sm bg-muted px-2.5 py-1 text-sm">
<Icon :name="typeIcon(t)" class="size-3.5" />
{{ typeLabel(t) }}
<button
type="button"
class="text-muted-foreground transition-colors hover:text-foreground"
:aria-label="$t('filters.removeType', { label: typeLabel(t) })"
@click="selectedTypes = selectedTypes.filter(v => v !== t)"
>
<Icon name="ph:x-fill" class="size-3.5" />
</button>
</span>
<span v-if="uploaderId" class="inline-flex items-center gap-1.5 rounded-sm bg-muted px-2.5 py-1 text-sm">
<Icon name="ph:user-fill" class="size-3.5" />
{{ selectedUser?.name ?? '…' }}
<button
type="button"
class="text-muted-foreground transition-colors hover:text-foreground"
:aria-label="$t('filters.removeUploader')"
@click="setQuery({ uploader: undefined })"
>
<Icon name="ph:x-fill" class="size-3.5" />
</button>
</span>
<span v-if="daysOption" class="inline-flex items-center gap-1.5 rounded-sm bg-muted px-2.5 py-1 text-sm">
<Icon name="ph:calendar-fill" class="size-3.5" />
{{ daysOption ? $t(daysOption.labelKey) : '' }}
<button
type="button"
class="text-muted-foreground transition-colors hover:text-foreground"
:aria-label="$t('filters.removePeriod')"
@click="setQuery({ days: undefined })"
>
<Icon name="ph:x-fill" class="size-3.5" />
</button>
</span>
</div>
</template>