forked from BRETT/BRETT
258 lines
10 KiB
Vue
258 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 TYPE_OPTIONS = [
|
|
{ value: 'image', label: 'Bilder', icon: 'ph:image-fill' },
|
|
{ value: 'video', label: 'Videos', icon: 'ph:video-camera-fill' },
|
|
{ value: 'audio', label: 'Audio', icon: 'ph:music-note-fill' },
|
|
{ value: 'youtube', label: 'YouTube', icon: 'simple-icons:youtube' },
|
|
{ value: 'twitch', label: 'Twitch', icon: 'simple-icons:twitch' },
|
|
{ value: 'link', label: 'Links', icon: 'ph:link-fill' },
|
|
] as const
|
|
|
|
const DAY_OPTIONS = [
|
|
{ value: '1', label: 'Heute' },
|
|
{ value: '7', label: '7 Tage' },
|
|
{ value: '30', label: '30 Tage' },
|
|
{ value: '365', label: 'Letztes Jahr' },
|
|
] 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) => TYPE_OPTIONS.find(t => t.value === value)?.label ?? 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" />
|
|
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>Typ</FieldLabel>
|
|
<ToggleGroup v-model="selectedTypes" type="multiple" variant="outline" :spacing="2" class="flex-wrap justify-start">
|
|
<ToggleGroupItem
|
|
v-for="t in TYPE_OPTIONS"
|
|
:key="t.value"
|
|
:value="t.value"
|
|
:aria-label="t.label"
|
|
class="gap-1.5 rounded-md px-2.5"
|
|
>
|
|
<Icon :name="t.icon" class="size-4" />
|
|
{{ t.label }}
|
|
</ToggleGroupItem>
|
|
</ToggleGroup>
|
|
</Field>
|
|
<Field>
|
|
<FieldLabel>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 ?? 'Alle Uploader' }}
|
|
</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="Nutzer suchen…" />
|
|
<CommandList>
|
|
<CommandEmpty>Kein Nutzer gefunden.</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')" />
|
|
Alle Uploader
|
|
</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>Zeitraum</FieldLabel>
|
|
<Select v-model="daysModel">
|
|
<SelectTrigger class="w-full">
|
|
<SelectValue placeholder="Alle" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Gesamter Zeitraum</SelectItem>
|
|
<SelectItem v-for="d in DAY_OPTIONS" :key="d.value" :value="d.value">{{ d.label }}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</Field>
|
|
<Button variant="ghost" size="sm" :disabled="!activeCount" class="self-start" @click="clearFilters">
|
|
Zurücksetzen
|
|
</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="Tag-Filter entfernen"
|
|
@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="`Typ-Filter ${typeLabel(t)} entfernen`"
|
|
@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="Uploader-Filter entfernen"
|
|
@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.label }}
|
|
<button
|
|
type="button"
|
|
class="text-muted-foreground transition-colors hover:text-foreground"
|
|
aria-label="Zeitraum-Filter entfernen"
|
|
@click="setQuery({ days: undefined })"
|
|
>
|
|
<Icon name="ph:x-fill" class="size-3.5" />
|
|
</button>
|
|
</span>
|
|
</div>
|
|
</template>
|