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:
@@ -96,7 +96,6 @@ onUnmounted(() => {
|
||||
size="sm"
|
||||
@click.stop="select(null)"
|
||||
>
|
||||
<Icon name="ph:x-bold" class="size-4" />
|
||||
{{ $t('common.remove') }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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() }}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user