Apply UI guidelines to admin, halls, stats, channels, users and login pages

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
2026-10-24 18:44:25 +02:00
co-authored by Devin
parent ab0734a7dd
commit 8a0c3f57d5
12 changed files with 99 additions and 99 deletions
-1
View File
@@ -96,7 +96,6 @@ onUnmounted(() => {
size="sm"
@click.stop="select(null)"
>
<Icon name="ph:x-bold" class="size-4" />
{{ $t('common.remove') }}
</Button>
</template>
+2 -2
View File
@@ -44,11 +44,11 @@ const visibilityIcon = computed(() => {
/>
<div class="absolute top-2 left-2 z-10 flex items-center gap-1">
<Badge v-if="hall.isStandard" variant="default" class="gap-1 px-1.5 py-0 text-[10px]">
<Badge v-if="hall.isStandard" variant="default" class="gap-1 px-1.5 py-0 text-xs">
<Icon name="ph:star-fill" class="size-3" />
{{ $t('halls.standardBadge') }}
</Badge>
<Badge v-else variant="secondary" class="gap-1 bg-background/85 px-1.5 py-0 text-[10px] backdrop-blur-xs">
<Badge v-else variant="secondary" class="gap-1 bg-background/85 px-1.5 py-0 text-xs backdrop-blur-xs">
<Icon :name="visibilityIcon" class="size-3" />
{{ $t(`halls.visibility.${hall.visibility}`) }}
</Badge>
+4 -9
View File
@@ -75,17 +75,12 @@ async function onSubmit() {
{{ $t('upload.upload') }}
</Button>
</PopoverTrigger>
<PopoverContent align="start" class="w-160">
<PopoverContent align="start" class="w-160 max-w-[calc(100vw-2rem)]">
<template v-if="user">
<PopoverHeader>
<PopoverTitle>{{ $t('upload.upload') }}</PopoverTitle>
<PopoverDescription class="flex flex-wrap items-center gap-x-3 gap-y-1">
<span class="flex items-center gap-1"><Icon name="ph:image-fill" class="size-3.5" />{{ $t('filters.images') }}</span>
<span class="flex items-center gap-1"><Icon name="ph:video-fill" class="size-3.5" />{{ $t('filters.videos') }}</span>
<span class="flex items-center gap-1"><Icon name="ph:music-note-fill" class="size-3.5" />{{ $t('filters.audio') }}</span>
<span class="flex items-center gap-1"><Icon name="simple-icons:youtube" class="size-3.5" />YouTube</span>
<span class="flex items-center gap-1"><Icon name="simple-icons:twitch" class="size-3.5" />Twitch</span>
<span class="flex items-center gap-1"><Icon name="ph:link-simple-duotone" class="size-3.5" />{{ $t('filters.links') }}</span>
<PopoverDescription>
{{ $t('filters.images') }} · {{ $t('filters.videos') }} · {{ $t('filters.audio') }} · YouTube · Twitch · {{ $t('filters.links') }}
</PopoverDescription>
</PopoverHeader>
<form class="space-y-4" @submit.prevent="onSubmit">
@@ -97,7 +92,7 @@ async function onSubmit() {
<Label for="upload-url">{{ $t('upload.orUrl') }}</Label>
<Input id="upload-url" v-model="url" type="url" placeholder="https://…" :disabled="!!file" />
</div>
<p v-if="errorMessage" class="text-sm text-destructive">
<p v-if="errorMessage" role="alert" class="text-sm text-destructive">
{{ errorMessage }}
</p>
<div v-if="loading && file" class="flex items-center gap-2.5">
+11 -10
View File
@@ -385,7 +385,7 @@ async function resetLimit(key: LimitKey) {
</script>
<template>
<div class="px-4 lg:px-6">
<div class="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<Tabs default-value="roles" class="gap-4">
<TabsList>
<TabsTrigger value="roles" class="cursor-pointer">
@@ -422,7 +422,7 @@ async function resetLimit(key: LimitKey) {
</CardHeader>
<CardContent class="space-y-4">
<ul class="space-y-2">
<li v-for="role in rolesList" :key="role.id" class="rounded-md border px-3 py-2">
<li v-for="role in rolesList" :key="role.id" class="rounded-md bg-muted/40 px-3 py-2">
<div class="flex items-center gap-3">
<span class="font-medium">{{ role.name }}</span>
<span class="text-sm text-muted-foreground">{{ $t('admin.memberCount', { n: role.memberCount }) }}</span>
@@ -496,7 +496,7 @@ async function resetLimit(key: LimitKey) {
</li>
</ul>
<form v-if="can('roles.manage')" class="flex gap-2" @submit.prevent="createRole">
<Input v-model="newRoleName" :placeholder="$t('admin.newRole')" maxlength="50" />
<Input v-model="newRoleName" :placeholder="$t('admin.newRole')" :aria-label="$t('admin.newRole')" maxlength="50" />
<Button type="submit" :disabled="!newRoleName.trim()">
{{ $t('admin.create') }}
</Button>
@@ -622,7 +622,7 @@ async function resetLimit(key: LimitKey) {
<li
v-for="e in group.list"
:key="e.id"
class="flex flex-col gap-2 rounded-md border p-2.5"
class="flex flex-col gap-2 rounded-md bg-muted/40 p-2"
>
<StickerMedia :sticker="e" variant="card" />
<div class="flex items-center justify-between gap-2">
@@ -697,7 +697,7 @@ async function resetLimit(key: LimitKey) {
<li
v-for="m in group.list"
:key="m.id"
class="flex flex-col gap-2 rounded-md border p-2.5"
class="flex flex-col gap-2 rounded-md bg-muted/40 p-2"
>
<img :src="m.url" :alt="m.name" class="aspect-square w-full rounded-md bg-muted/40 object-contain">
<div class="flex items-start justify-between gap-1.5">
@@ -705,13 +705,13 @@ async function resetLimit(key: LimitKey) {
<span class="truncate text-sm font-medium">{{ m.name }}</span>
<span class="truncate text-xs text-muted-foreground">{{ m.userName ?? '-' }}</span>
<div class="mt-1 flex flex-wrap gap-1">
<Badge v-if="m.isStandard" variant="default" class="px-1.5 py-0 text-[10px]">
<Badge v-if="m.isStandard" variant="default" class="px-1.5 py-0 text-xs">
{{ $t('memes.badgeStandard') }}
</Badge>
<Badge v-else-if="m.isPublic" variant="secondary" class="px-1.5 py-0 text-[10px]">
<Badge v-else-if="m.isPublic" variant="secondary" class="px-1.5 py-0 text-xs">
{{ $t('memes.visibilityPublic') }}
</Badge>
<Badge v-else variant="outline" class="px-1.5 py-0 text-[10px]">
<Badge v-else variant="outline" class="px-1.5 py-0 text-xs">
{{ $t('memes.badgePrivate') }}
</Badge>
</div>
@@ -822,7 +822,7 @@ async function resetLimit(key: LimitKey) {
@update:model-value="setSiteFlag('registrationOpen', $event)"
/>
</div>
<p v-if="siteFlagError" class="text-sm text-destructive">
<p v-if="siteFlagError" role="alert" class="text-sm text-destructive">
{{ siteFlagError }}
</p>
</CardContent>
@@ -837,6 +837,7 @@ async function resetLimit(key: LimitKey) {
<Textarea
v-model="motdDraft"
:placeholder="$t('admin.motdPlaceholder')"
:aria-label="$t('admin.motd')"
maxlength="500"
class="min-h-16"
/>
@@ -907,7 +908,7 @@ async function resetLimit(key: LimitKey) {
</Card>
</TabsContent>
</Tabs>
<p v-if="errorMessage" class="mt-4 text-sm text-destructive">
<p v-if="errorMessage" role="alert" class="mt-4 text-sm text-destructive">
{{ errorMessage }}
</p>
</div>
+13 -9
View File
@@ -3,6 +3,7 @@ import { toast } from 'vue-sonner'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Skeleton } from '@/components/ui/skeleton'
import { Spinner } from '@/components/ui/spinner'
interface ChannelSubscriber {
@@ -25,7 +26,7 @@ const { t } = useI18n()
useHead({ title: computed(() => t('nav.channels')) })
const { data: channels, refresh } = await useFetch<TrackedChannel[]>('/api/youtube/channels', {
const { data: channels, status, refresh } = await useFetch<TrackedChannel[]>('/api/youtube/channels', {
key: 'youtube-channels',
default: () => [],
})
@@ -90,20 +91,20 @@ async function unsubscribe(channel: TrackedChannel) {
</script>
<template>
<div class="mx-auto w-full max-w-3xl px-4 lg:px-6">
<div class="mx-auto w-full max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Icon name="ph:youtube-logo-fill" class="size-5" />
<CardTitle>
{{ $t('nav.channels') }}
</CardTitle>
<CardDescription>{{ $t('channels.desc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-5">
<CardContent class="space-y-4">
<form v-if="user" class="flex gap-2" @submit.prevent="addChannel">
<Input
v-model="input"
:placeholder="$t('channels.placeholder')"
:aria-label="$t('channels.placeholder')"
class="flex-1"
/>
<Button type="submit" :disabled="submitting || !input.trim()">
@@ -112,17 +113,20 @@ async function unsubscribe(channel: TrackedChannel) {
</Button>
</form>
<p v-if="!channels.length" class="text-sm text-muted-foreground">
<ul v-if="status === 'pending'" class="flex flex-col gap-2">
<Skeleton v-for="i in 3" :key="i" class="h-14 rounded-md" />
</ul>
<p v-else-if="!channels.length" class="text-sm text-muted-foreground">
{{ $t('channels.empty') }}
</p>
<ul v-else class="space-y-3">
<ul v-else class="flex flex-col gap-2">
<li
v-for="channel in channels"
:key="channel.id"
class="flex items-center gap-3 rounded-md border px-3 py-2.5"
class="flex items-center gap-3 rounded-md bg-muted/40 px-3 py-2"
>
<Icon name="ph:youtube-logo-fill" class="size-5 shrink-0 text-muted-foreground" />
<div class="min-w-0 flex-1">
<a
:href="channel.url"
+6 -7
View File
@@ -198,14 +198,14 @@ const visibilityIcon = computed(() => {
</script>
<template>
<div class="px-4 py-4 lg:px-6">
<p v-if="error" class="text-muted-foreground">
<div class="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<p v-if="error" class="text-sm text-muted-foreground">
{{ $t('halls.notFound') }}
</p>
<template v-else-if="data">
<Card class="max-w-4xl">
<CardContent class="flex flex-col gap-5 sm:flex-row sm:items-center">
<CardContent class="flex flex-col gap-4 sm:flex-row sm:items-center">
<div class="relative aspect-video w-full shrink-0 overflow-hidden rounded-md bg-muted sm:w-56">
<HallCoverStack
:thumbs="data.hall.coverThumbUrls"
@@ -216,7 +216,7 @@ const visibilityIcon = computed(() => {
<div class="flex min-w-0 flex-1 flex-col gap-2">
<div class="flex flex-wrap items-center gap-2">
<h1 class="truncate text-2xl font-bold">
<h1 class="truncate text-2xl font-semibold tracking-tight">
{{ data.hall.title }}
</h1>
<Badge v-if="data.hall.isStandard" variant="default" class="gap-1">
@@ -273,7 +273,6 @@ const visibilityIcon = computed(() => {
class="cursor-pointer gap-1.5"
@click="openEditDialog"
>
<Icon name="ph:pencil-simple-fill" class="size-4" />
{{ $t('halls.edit') }}
</Button>
@@ -318,11 +317,11 @@ const visibilityIcon = computed(() => {
</p>
<!-- Sortier-/Verwaltungsansicht -->
<div v-else-if="manageMode" class="flex max-w-3xl flex-col divide-y rounded-md border bg-card">
<div v-else-if="manageMode" class="flex max-w-3xl flex-col gap-2">
<div
v-for="(item, idx) in data.items"
:key="item.id"
class="flex items-center gap-3 px-3 py-2 text-sm"
class="flex items-center gap-3 rounded-md bg-muted/40 px-3 py-2 text-sm"
>
<span class="w-6 text-center text-xs tabular-nums text-muted-foreground">
{{ idx + 1 }}
+14 -22
View File
@@ -21,6 +21,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Spinner } from '@/components/ui/spinner'
import { Textarea } from '@/components/ui/textarea'
@@ -85,10 +86,10 @@ async function createHall() {
</script>
<template>
<div class="mx-auto flex w-full max-w-7xl flex-col gap-8 px-4 py-4 lg:px-6">
<div class="mx-auto flex w-full max-w-7xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-bold">
<h1 class="text-xl font-semibold tracking-tight">
{{ $t('halls.title') }}
</h1>
<p class="text-sm text-muted-foreground">
@@ -101,19 +102,16 @@ async function createHall() {
</Button>
</div>
<div v-if="pending" class="flex items-center justify-center py-12 text-muted-foreground">
<Spinner />
<div v-if="pending" class="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
<Skeleton v-for="i in 8" :key="i" class="aspect-video rounded-lg" />
</div>
<template v-else>
<!-- Sektion 1: Standard-Hallen (immer für alle sichtbar) -->
<section class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<Icon name="at-icons:trophy" class="size-5 text-primary" />
<h2 class="text-lg font-semibold">
{{ $t('halls.standardSection') }}
</h2>
</div>
<h2 class="text-lg font-semibold tracking-tight">
{{ $t('halls.standardSection') }}
</h2>
<p v-if="!data.standard.length" class="text-sm text-muted-foreground">
{{ $t('halls.noStandard') }}
</p>
@@ -128,12 +126,9 @@ async function createHall() {
<!-- Sektion 2: Eigene Hallen -->
<section v-if="user" class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<Icon name="ph:bookmarks-simple-fill" class="size-5 text-primary" />
<h2 class="text-lg font-semibold">
{{ $t('halls.mineSection') }}
</h2>
</div>
<h2 class="text-lg font-semibold tracking-tight">
{{ $t('halls.mineSection') }}
</h2>
<p v-if="!data.mine.length" class="text-sm text-muted-foreground">
{{ $t('halls.noOwnYet') }}
</p>
@@ -148,12 +143,9 @@ async function createHall() {
<!-- Sektion 3: Öffentliche Hallen anderer User -->
<section class="flex flex-col gap-3">
<div class="flex items-center gap-2">
<Icon name="ph:globe-fill" class="size-5 text-primary" />
<h2 class="text-lg font-semibold">
{{ $t('halls.communitySection') }}
</h2>
</div>
<h2 class="text-lg font-semibold tracking-tight">
{{ $t('halls.communitySection') }}
</h2>
<p v-if="!data.community.length" class="text-sm text-muted-foreground">
{{ $t('halls.noCommunity') }}
</p>
+1 -1
View File
@@ -34,7 +34,7 @@ async function onSubmit() {
</script>
<template>
<div class="mx-auto w-full max-w-3xl px-4 lg:px-6">
<div class="mx-auto w-full max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
<Card>
<CardHeader>
<CardTitle>{{ $t('auth.loginTitle') }}</CardTitle>
+1 -1
View File
@@ -231,7 +231,7 @@ async function save() {
</script>
<template>
<div class="mx-auto w-full max-w-3xl px-4 lg:px-6">
<div class="mx-auto w-full max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
<div v-if="user" class="space-y-4">
<div class="space-y-1">
<h1 class="text-2xl font-semibold tracking-tight">{{ $t('settings.title') }}</h1>
+19 -21
View File
@@ -34,11 +34,11 @@ const fmt = (n: number) => numberFmt.value.format(n)
const statCards = computed(() => stats.value
? [
{ icon: 'ph:upload-simple-fill', label: t('stats.uploads'), value: stats.value.uploadsTotal },
{ icon: 'ph:chats-circle-fill', label: t('stats.comments'), value: stats.value.commentsTotal },
{ icon: 'ph:smiley-fill', label: t('stats.reactions'), value: stats.value.reactionsTotal },
{ icon: 'ph:heart-fill', label: t('stats.favorites'), value: stats.value.favoritesTotal },
{ icon: 'ph:users-fill', label: t('stats.users'), value: stats.value.usersTotal },
{ label: t('stats.uploads'), value: stats.value.uploadsTotal },
{ label: t('stats.comments'), value: stats.value.commentsTotal },
{ label: t('stats.reactions'), value: stats.value.reactionsTotal },
{ label: t('stats.favorites'), value: stats.value.favoritesTotal },
{ label: t('stats.users'), value: stats.value.usersTotal },
]
: [])
@@ -81,7 +81,7 @@ const typeLabel = (type: string) => te(`stats.type.${type}`) ? t(`stats.type.${t
const typeColor = (d: StatsTypePoint) => TYPE_COLORS[d.type] ?? 'var(--muted-foreground)'
const donutTriggers = computed(() => ({
[VisDonutSelectors.segment]: (d: { data: StatsTypePoint }) =>
`<div class="bg-popover text-popover-foreground ring-foreground/10 flex items-center gap-2 rounded-xl px-2.5 py-1.5 text-xs shadow-lg ring-1">
`<div class="bg-popover text-popover-foreground ring-foreground/10 flex items-center gap-2 rounded-md px-2.5 py-1.5 text-xs shadow-lg ring-1">
<span class="size-2.5 rounded-xs" style="background:${typeColor(d.data)}"></span>
<span class="font-medium">${typeLabel(d.data.type)}</span>
<span class="font-mono tabular-nums">${fmt(d.data.count)}</span>
@@ -110,17 +110,17 @@ const boardSections = computed(() => stats.value
key: 'weekly',
title: t('stats.weekly'),
boards: [
{ key: 'uploads', icon: 'ph:upload-simple-fill', title: t('stats.topUploaders'), list: stats.value.payload.weeklyUploaders ?? [] },
{ key: 'comments', icon: 'ph:chats-circle-fill', title: t('stats.topCommenters'), list: stats.value.payload.weeklyCommenters ?? [] },
{ key: 'uploads', title: t('stats.topUploaders'), list: stats.value.payload.weeklyUploaders ?? [] },
{ key: 'comments', title: t('stats.topCommenters'), list: stats.value.payload.weeklyCommenters ?? [] },
],
},
{
key: 'allTime',
title: t('stats.allTime'),
boards: [
{ key: 'uploads', icon: 'ph:upload-simple-fill', title: t('stats.topUploaders'), list: stats.value.payload.topUploaders },
{ key: 'comments', icon: 'ph:chats-circle-fill', title: t('stats.topCommenters'), list: stats.value.payload.topCommenters },
{ key: 'favorites', icon: 'ph:heart-fill', title: t('stats.topFavoriters'), list: stats.value.payload.topFavoriters },
{ key: 'uploads', title: t('stats.topUploaders'), list: stats.value.payload.topUploaders },
{ key: 'comments', title: t('stats.topCommenters'), list: stats.value.payload.topCommenters },
{ key: 'favorites', title: t('stats.topFavoriters'), list: stats.value.payload.topFavoriters },
],
},
]
@@ -128,9 +128,9 @@ const boardSections = computed(() => stats.value
</script>
<template>
<div class="px-4 pb-6 lg:px-6">
<div class="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div class="mb-4 flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h1 class="text-xl font-semibold">
<h1 class="text-xl font-semibold tracking-tight">
{{ $t('stats.title') }}
</h1>
<span v-if="stats" class="text-xs text-muted-foreground">
@@ -146,15 +146,14 @@ const boardSections = computed(() => stats.value
<template v-else-if="stats">
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-5">
<Card v-for="card in statCards" :key="card.icon">
<CardHeader class="flex-row items-center gap-2 space-y-0 pb-2">
<Icon :name="card.icon" class="size-4 text-primary" />
<Card v-for="card in statCards" :key="card.label">
<CardHeader class="pb-2">
<CardTitle class="text-xs font-medium text-muted-foreground">
{{ card.label }}
</CardTitle>
</CardHeader>
<CardContent>
<p class="text-2xl font-bold tabular-nums">
<p class="text-2xl font-semibold tabular-nums">
{{ fmt(card.value) }}
</p>
</CardContent>
@@ -204,7 +203,7 @@ const boardSections = computed(() => stats.value
/>
</VisSingleContainer>
</ChartContainer>
<ul class="mt-3 space-y-1.5">
<ul class="mt-3 flex flex-col gap-1.5">
<li v-for="d in byType" :key="d.type" class="flex items-center gap-2 text-sm">
<span class="size-2.5 shrink-0 rounded-xs" :style="{ background: typeColor(d) }" />
<span class="min-w-0 flex-1 text-muted-foreground">{{ typeLabel(d.type) }}</span>
@@ -245,8 +244,7 @@ const boardSections = computed(() => stats.value
>
<Card v-for="board in section.boards" :key="board.key">
<CardHeader>
<CardTitle class="flex items-center gap-2 text-sm font-medium">
<Icon :name="board.icon" class="size-4 text-primary" />
<CardTitle class="text-sm font-medium">
{{ board.title }}
</CardTitle>
</CardHeader>
@@ -264,7 +262,7 @@ const boardSections = computed(() => stats.value
<Avatar class="size-7 shrink-0">
<AvatarImage v-if="u.avatarUrl" :src="u.avatarUrl" :alt="u.name" />
<AvatarFallback
class="text-[10px]"
class="text-xs"
:style="u.color ? { backgroundColor: u.color, color: '#fff' } : undefined"
>
{{ u.name.slice(0, 2).toUpperCase() }}
+2 -3
View File
@@ -260,7 +260,7 @@ const timeAgo = useTimeAgo()
<CardVignette v-if="data.user.cardVignette" surface="var(--color-card)" />
</div>
<CardBorderEffect v-if="profileBorderEffect" :effect="profileBorderEffect" :color="profileBorderColor" class="-z-10" />
<CardContent class="flex items-center gap-5">
<CardContent class="flex items-center gap-4">
<NuxtImg
v-if="data.user.avatarUrl"
:src="data.user.avatarUrl"
@@ -347,10 +347,9 @@ const timeAgo = useTimeAgo()
<Button
variant="outline"
size="sm"
class="cursor-pointer gap-1.5"
class="cursor-pointer"
@click="openFloatingChat({ id: data.user.id, name: data.user.name, color: data.user.color, avatarUrl: data.user.avatarUrl })"
>
<Icon name="ph:chats-circle-fill" class="size-4" />
{{ $t('dm.message') }}
</Button>
</div>
+26 -13
View File
@@ -4,6 +4,7 @@ import type { CardEffectKey } from '~~/shared/utils/cardEffects'
import CardBackgroundEffect from '@/components/effects/CardBackgroundEffect.vue'
import CardBorderEffect from '@/components/effects/CardBorderEffect.vue'
import CardVignette from '@/components/effects/CardVignette.vue'
import { Skeleton } from '@/components/ui/skeleton'
import { isCardBorderEffect } from '~~/shared/utils/cardBorderEffects'
import { isCardEffect } from '~~/shared/utils/cardEffects'
import { cn } from '@/lib/utils'
@@ -29,10 +30,11 @@ const { t, locale } = useI18n()
useHead({ title: computed(() => t('nav.users')) })
const { data: users } = await useFetch<ListedUser[]>('/api/users', {
const { data: users, status } = await useFetch<ListedUser[]>('/api/users', {
key: 'users-list',
default: () => [],
})
const pending = computed(() => status.value === 'pending')
function cardEffect(user: ListedUser): CardEffectKey | null {
return user.cardEffect && isCardEffect(user.cardEffect) ? user.cardEffect : null
@@ -50,17 +52,17 @@ function joinDate(value: string) {
</script>
<template>
<div class="px-4 lg:px-6">
<h1 class="mb-4 text-xl font-semibold">
<div class="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<h1 class="mb-4 text-xl font-semibold tracking-tight">
{{ $t('nav.users') }}
</h1>
<p v-if="!users.length" class="text-sm text-muted-foreground">
<p v-if="!pending && !users.length" class="text-sm text-muted-foreground">
{{ $t('misc.noUsers') }}
</p>
<div v-else class="max-w-4xl overflow-x-auto">
<div :class="cn('grid items-center gap-3 px-3 py-2 text-xs text-muted-foreground', GRID_COLS)">
<div :class="cn('grid items-center gap-3 px-3 py-2 text-xs font-medium text-muted-foreground', GRID_COLS)">
<span />
<span>{{ $t('common.name') }}</span>
<span class="text-right">ID</span>
@@ -69,15 +71,26 @@ function joinDate(value: string) {
<span class="hidden text-right sm:block">{{ $t('misc.joined') }}</span>
</div>
<div class="mt-1 flex flex-col gap-2">
<div class="mt-2 flex flex-col gap-2">
<template v-if="pending">
<div
v-for="i in 6"
:key="i"
:class="cn('grid items-center gap-3 rounded-md bg-muted/40 px-3 py-2', GRID_COLS)"
>
<Skeleton class="size-10 rounded-sm" />
<Skeleton class="h-4 w-32" />
<Skeleton class="ml-auto h-4 w-6" />
<Skeleton class="ml-auto hidden h-4 w-8 sm:block" />
<Skeleton class="ml-auto hidden h-4 w-8 sm:block" />
<Skeleton class="ml-auto hidden h-4 w-24 sm:block" />
</div>
</template>
<NuxtLink
v-for="user in users"
v-for="user in pending ? [] : users"
:key="user.id"
:to="`/user/${user.id}`"
:class="cn(
'relative block overflow-hidden rounded-md border transition-colors',
cardBorderEffect(user) ? 'border-transparent' : 'hover:border-primary/50',
)"
class="relative block overflow-hidden rounded-md bg-muted/40 transition-colors hover:bg-muted/60"
>
<div v-if="cardEffect(user)" class="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit] [clip-path:inset(0_round_var(--radius-md))]">
<CardBackgroundEffect :effect="cardEffect(user)!" :color="user.cardEffectColor ?? user.color ?? '#6366f1'" />
@@ -86,7 +99,7 @@ function joinDate(value: string) {
</div>
<CardBorderEffect v-if="cardBorderEffect(user)" :effect="cardBorderEffect(user)!" :color="user.cardBorderEffectColor ?? user.color ?? '#6366f1'" />
<div :class="cn('relative grid items-center gap-3 px-3 py-2.5', GRID_COLS)">
<div :class="cn('relative grid items-center gap-3 px-3 py-2', GRID_COLS)">
<NuxtImg
v-if="user.avatarUrl"
:src="user.avatarUrl"
@@ -102,7 +115,7 @@ function joinDate(value: string) {
:style="user.color ? { backgroundColor: user.color, color: '#fff' } : { backgroundColor: 'var(--color-muted)' }"
>{{ user.name.slice(0, 2).toUpperCase() }}</span>
<span
class="truncate font-medium"
class="truncate font-semibold tracking-tight"
:style="user.color ? { color: user.color } : undefined"
>{{ user.name }}</span>
<span class="text-right text-sm text-muted-foreground">{{ user.id }}</span>