Initial Commit

This commit is contained in:
2026-10-03 20:07:49 +02:00
commit a1346c98b1
427 changed files with 59320 additions and 0 deletions
+334
View File
@@ -0,0 +1,334 @@
<script setup lang="ts">
import type { ColumnDef } from '@tanstack/vue-table'
import { PERMISSIONS, SYSTEM_ROLES } from '~~/shared/utils/permissions'
import type { DataTableFeatures } from '@/lib/table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
definePageMeta({ middleware: 'admin' })
interface Role {
id: number
name: string
memberCount: number
permissions: string[]
}
interface AdminUser {
id: number
name: string
email: string | null
roles: { id: number, name: string }[]
}
const { user, can } = useAuth()
const { data: rolesList, refresh: refreshRoles } = await useFetch<Role[]>('/api/admin/roles', { default: () => [] })
const { data: usersList, refresh: refreshUsers } = await useFetch<AdminUser[]>('/api/admin/users', { default: () => [] })
const newRoleName = ref('')
const motdDraft = ref('')
const motdSaving = ref(false)
const { data: motdData } = await useFetch<{ message: string }>('/api/motd', { default: () => ({ message: '' }) })
motdDraft.value = motdData.value.message
const errorMessage = ref('')
const selectedRole = ref<Record<number, number | undefined>>({})
const editedPermissions = ref<Record<number, string[]>>({})
const savingRole = ref<number | null>(null)
const userColumns: ColumnDef<DataTableFeatures, AdminUser>[] = [
{ accessorKey: 'name', header: 'Name' },
{ accessorKey: 'email', header: 'E-Mail', cell: ({ getValue }) => getValue() ?? '—' },
{ id: 'roles', header: 'Rollen', enableSorting: false, enableGlobalFilter: false },
{ id: 'actions', header: 'Aktion', enableSorting: false, enableGlobalFilter: false },
]
function userSearch(u: AdminUser, query: string) {
const q = query.toLowerCase()
return u.name.toLowerCase().includes(q) || (u.email ?? '').toLowerCase().includes(q)
}
function errText(err: unknown) {
const e = err as { data?: { statusMessage?: string, message?: string } }
return e.data?.statusMessage ?? e.data?.message ?? 'Aktion fehlgeschlagen'
}
async function refreshAll() {
await Promise.all([refreshRoles(), refreshUsers()])
}
async function createRole() {
if (!newRoleName.value.trim()) return
errorMessage.value = ''
try {
await $fetch('/api/admin/roles', { method: 'POST', body: { name: newRoleName.value.trim() } })
newRoleName.value = ''
await refreshRoles()
}
catch (err) {
errorMessage.value = errText(err)
}
}
async function deleteRole(roleId: number) {
errorMessage.value = ''
try {
await $fetch(`/api/admin/roles/${roleId}`, { method: 'DELETE' })
await refreshAll()
}
catch (err) {
errorMessage.value = errText(err)
}
}
async function addRole(userId: number) {
const roleId = selectedRole.value[userId]
if (!roleId) return
errorMessage.value = ''
try {
await $fetch(`/api/admin/users/${userId}/roles`, { method: 'POST', body: { roleId } })
selectedRole.value[userId] = undefined
await refreshAll()
}
catch (err) {
errorMessage.value = errText(err)
}
}
async function removeRole(userId: number, roleId: number) {
errorMessage.value = ''
try {
await $fetch(`/api/admin/users/${userId}/roles/${roleId}`, { method: 'DELETE' })
await refreshAll()
}
catch (err) {
errorMessage.value = errText(err)
}
}
function permissionsDraft(role: Role) {
return editedPermissions.value[role.id] ?? role.permissions
}
function isDirty(role: Role) {
const draft = editedPermissions.value[role.id]
return !!draft && (draft.length !== role.permissions.length || draft.some(k => !role.permissions.includes(k)))
}
function togglePermission(role: Role, key: string, checked: boolean | 'indeterminate') {
const current = permissionsDraft(role)
editedPermissions.value[role.id] = checked === true
? [...new Set([...current, key])]
: current.filter(k => k !== key)
}
async function savePermissions(role: Role) {
const draft = editedPermissions.value[role.id]
if (!draft) return
errorMessage.value = ''
savingRole.value = role.id
try {
await $fetch(`/api/admin/roles/${role.id}/permissions`, { method: 'PUT', body: { permissions: draft } })
delete editedPermissions.value[role.id]
await refreshRoles()
}
catch (err) {
errorMessage.value = errText(err)
}
finally {
savingRole.value = null
}
}
function resetPermissions(role: Role) {
delete editedPermissions.value[role.id]
}
async function saveMotd() {
motdSaving.value = true
errorMessage.value = ''
try {
const res = await $fetch<{ message: string }>('/api/admin/motd', {
method: 'PUT',
body: { message: motdDraft.value },
})
motdDraft.value = res.message
}
catch (err) {
errorMessage.value = errText(err)
}
finally {
motdSaving.value = false
}
}
</script>
<template>
<div class="grid gap-6 px-4 lg:grid-cols-2 lg:px-6">
<Card>
<CardHeader>
<CardTitle>Rollen</CardTitle>
<CardDescription>Bestehende Rollen und neue anlegen (z. B. moderator). Die admin-Rolle hat immer alle Berechtigungen.</CardDescription>
</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">
<div class="flex items-center gap-3">
<span class="font-medium">{{ role.name }}</span>
<span class="text-sm text-muted-foreground">{{ role.memberCount }} Nutzer</span>
<Button
v-if="!SYSTEM_ROLES.includes(role.name as 'admin' | 'moderator') && can('roles.manage')"
variant="ghost"
size="icon-sm"
class="ml-auto text-muted-foreground"
@click="deleteRole(role.id)"
>
<Icon name="ph:trash-fill" class="size-4" />
<span class="sr-only">Rolle löschen</span>
</Button>
</div>
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-1">
<label
v-for="perm in PERMISSIONS"
:key="perm.key"
class="flex items-center gap-1.5 text-sm"
>
<Checkbox
:model-value="role.name === 'admin' || permissionsDraft(role).includes(perm.key)"
:disabled="role.name === 'admin' || !can('roles.manage') || savingRole === role.id"
@update:model-value="togglePermission(role, perm.key, $event)"
/>
{{ perm.label }}
</label>
</div>
<div v-if="isDirty(role)" class="mt-2 flex items-center gap-2">
<Button
size="sm"
:disabled="savingRole === role.id"
@click="savePermissions(role)"
>
{{ savingRole === role.id ? 'Speichern…' : 'Speichern' }}
</Button>
<Button
size="sm"
variant="outline"
:disabled="savingRole === role.id"
@click="resetPermissions(role)"
>
Zurücksetzen
</Button>
</div>
</li>
</ul>
<form v-if="can('roles.manage')" class="flex gap-2" @submit.prevent="createRole">
<Input v-model="newRoleName" placeholder="Neuer Rollenname" maxlength="50" />
<Button type="submit" :disabled="!newRoleName.trim()">
Anlegen
</Button>
</form>
</CardContent>
</Card>
<Card v-if="can('users.manage')">
<CardHeader>
<CardTitle>Benutzer</CardTitle>
<CardDescription>Rollen zuweisen oder entfernen.</CardDescription>
</CardHeader>
<CardContent>
<DataTable
:columns="userColumns"
:data="usersList"
search-placeholder="Name oder E-Mail suchen…"
:search-fn="userSearch"
>
<template #cell-name="{ row }">
<span class="font-medium">{{ row.original.name }}</span>
</template>
<template #cell-roles="{ row }">
<div class="flex flex-wrap items-center gap-1.5">
<Badge v-for="r in row.original.roles" :key="r.id" variant="secondary" class="gap-1">
{{ r.name }}
<button
v-if="!(r.name === 'admin' && row.original.id === user?.id)"
type="button"
class="opacity-60 hover:opacity-100"
@click="removeRole(row.original.id, r.id)"
>
<Icon name="ph:x-fill" class="size-3" />
<span class="sr-only">Rolle {{ r.name }} entfernen</span>
</button>
</Badge>
<span v-if="!row.original.roles.length" class="text-xs text-muted-foreground">keine Rollen</span>
</div>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-2">
<Select
:model-value="selectedRole[row.original.id]"
:disabled="!rolesList.some(r => !row.original.roles.some(ur => ur.id === r.id))"
@update:model-value="selectedRole[row.original.id] = $event === undefined ? undefined : Number($event)"
>
<SelectTrigger size="sm" class="w-40" aria-label="Rolle wählen">
<SelectValue placeholder="Rolle wählen…" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="role in rolesList.filter(r => !row.original.roles.some(ur => ur.id === r.id))"
:key="role.id"
:value="role.id"
>
{{ role.name }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
:disabled="!selectedRole[row.original.id]"
@click="addRole(row.original.id)"
>
Zuweisen
</Button>
</div>
</template>
</DataTable>
</CardContent>
</Card>
<Card v-if="can('site.manage')" class="lg:col-span-2">
<CardHeader>
<CardTitle>Ansage</CardTitle>
<CardDescription>Wird allen Besuchern direkt unter der Kopfzeile angezeigt und bei Änderung sofort aktualisiert. Leer lassen, um sie zu entfernen.</CardDescription>
</CardHeader>
<CardContent>
<form class="flex flex-col gap-2 sm:flex-row sm:items-end" @submit.prevent="saveMotd">
<Textarea
v-model="motdDraft"
placeholder="Ansagetext…"
maxlength="500"
class="min-h-16"
/>
<Button type="submit" class="shrink-0" :disabled="motdSaving">
{{ motdSaving ? 'Speichern…' : 'Speichern' }}
</Button>
</form>
</CardContent>
</Card>
<p v-if="errorMessage" class="text-sm text-destructive lg:col-span-2">
{{ errorMessage }}
</p>
</div>
</template>
+235
View File
@@ -0,0 +1,235 @@
<script setup lang="ts">
import { useDropZone, useEventListener } from '@vueuse/core'
import { toast } from 'vue-sonner'
import { isMediaFile, isMediaItemType } from '~~/shared/utils/media'
import UploadFilters from '@/components/UploadFilters.vue'
import UploadTile from '@/components/UploadTile.vue'
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination'
interface UploadItem {
id: string
type: 'image' | 'video' | 'audio' | 'youtube' | 'twitch' | 'link'
url: string
userId: number
userName: string
userColor: string | null
userAvatarUrl: string | null
commentCount: number
createdAt: string
}
interface UploadMeta {
id: string
thumbUrl: string | null
}
interface UploadList {
items: UploadItem[]
total: number
pageCount: number
}
const route = useRoute()
const router = useRouter()
const FILTER_KEYS = ['tag', 'type', 'uploader', 'days'] as const
const hasFilters = computed(() => FILTER_KEYS.some(k => typeof route.query[k] === 'string' && route.query[k]))
const filterRowEl = ref<HTMLElement>()
const { cells, cols } = usePageSize(filterRowEl)
const { uniform } = useGridMode()
const page = computed<number>({
get: () => Math.max(1, Math.floor(Number(route.query.page)) || 1),
set: (v) => {
const query = { ...route.query }
if (v > 1) query.page = String(v)
else delete query.page
router.replace({ query })
},
})
// Filter-Wechsel setzt zurück auf Seite 1
watch(() => FILTER_KEYS.map(k => route.query[k]).join('|'), () => {
if (page.value !== 1) page.value = 1
})
const fetchUrl = computed(() => {
const params = new URLSearchParams()
for (const key of FILTER_KEYS) {
const value = route.query[key]
if (typeof value === 'string' && value) params.set(key, value)
}
params.set('page', String(page.value))
params.set('cells', String(cells.value))
params.set('cols', String(cols.value))
params.set('mode', uniform.value ? 'uniform' : 'bento')
return `/api/uploads?${params.toString()}`
})
// server: false — die Seitengröße hängt vom Viewport ab, den SSR nicht kennt.
// Ein SSR-Fetch würde mit Fallback-Werten laden und müsste ohnehin ersetzt
// werden; der Client-Fetch läuft einmalig mit den gemessenen Parametern.
const { data: result, pending } = await useFetch<UploadList>(fetchUrl, {
key: 'uploads',
server: false,
watch: [fetchUrl],
})
const uploads = computed(() => result.value?.items ?? [])
const total = computed(() => result.value?.total ?? 0)
const pageCount = computed(() => result.value?.pageCount ?? 0)
// Items können durch neue Uploads/Löschungen/Live-Updates weniger Seiten werden
watch(pageCount, (count) => {
if (count > 0 && page.value > count) page.value = count
})
// Thumbnails + Kommentarzahlen asynchron nachladen — das Grid rendert sofort
const meta = ref<Record<string, UploadMeta>>({})
watch(uploads, async (list) => {
if (!import.meta.client) return
if (!list?.length) {
meta.value = {}
return
}
try {
const res = await $fetch<UploadMeta[]>('/api/uploads/meta', {
query: { ids: list.map(u => u.id).join(',') },
})
meta.value = Object.fromEntries(res.map(m => [m.id, m]))
}
catch {
// Meta bleibt leer — Tiles zeigen Platzhalter
}
}, { immediate: true })
useHead({
link: [
{ rel: 'preconnect', href: 'https://www.youtube.com' },
{ rel: 'preconnect', href: 'https://i.ytimg.com' },
],
})
// Originale vorladen, damit die View-Transition das echte Bild morpht statt eines leeren Platzhalters
watch(uploads, (list) => {
if (!import.meta.client) return
for (const u of list ?? []) {
if (u.type === 'image' && u.url) {
const img = new Image()
img.src = u.url
}
}
}, { immediate: true })
const { user } = useAuth()
const dropTarget = shallowRef<HTMLElement>()
const uploading = ref(false)
onMounted(() => {
dropTarget.value = document.body
})
const { isOverDropZone } = useDropZone(dropTarget, {
dataTypes: types => types.some(isMediaItemType),
onDrop(files) {
const valid = (files ?? []).filter(isMediaFile)
if (valid.length && !uploading.value) uploadFiles(valid)
else if (files?.length) toast.error('Nur Bilder, Videos und Audiodateien sind erlaubt')
},
})
// Verhindert, dass der Browser gedroppte Dateien außerhalb der Zone als Tab öffnet
useEventListener('dragover', (e: DragEvent) => {
if (e.dataTransfer?.types.includes('Files')) e.preventDefault()
})
useEventListener('drop', (e: DragEvent) => {
if (e.dataTransfer?.types.includes('Files')) e.preventDefault()
})
async function uploadFiles(files: File[]) {
if (!user.value) {
toast.error('Melde dich an, um hochzuladen.')
return
}
uploading.value = true
try {
const results = await Promise.allSettled(files.map((file) => {
const formData = new FormData()
formData.append('file', file)
return $fetch('/api/uploads', { method: 'POST', body: formData })
}))
const failed = results.filter(r => r.status === 'rejected').length
if (failed) {
toast.error(`${failed} von ${files.length} Dateien fehlgeschlagen`)
}
await refreshNuxtData('uploads')
await navigateTo('/')
}
finally {
uploading.value = false
}
}
</script>
<template>
<div class="flex flex-1 flex-col px-4 lg:px-6">
<div
v-if="isOverDropZone || uploading"
class="pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm"
>
<div class="flex flex-col items-center gap-3 rounded-lg border-2 border-dashed border-primary bg-background p-10">
<Icon name="ph:cloud-arrow-up-fill" class="size-12 text-primary" />
<p class="text-lg font-medium">
{{ uploading ? 'Lädt hoch…' : 'Zum Hochladen loslassen' }}
</p>
</div>
</div>
<div ref="filterRowEl" class="mb-2 flex items-center gap-2">
<UploadFilters />
<span v-if="hasFilters" class="text-sm text-muted-foreground">{{ total }} Uploads</span>
</div>
<p v-if="pending" class="text-muted-foreground">
Lade Uploads…
</p>
<p v-else-if="!uploads.length" class="text-muted-foreground">
{{ hasFilters ? 'Keine Uploads für die aktiven Filter.' : 'Noch keine Uploads. Sei der Erste!' }}
</p>
<div v-else class="grid grid-flow-dense grid-cols-4 gap-2 [grid-auto-rows:6.5rem] sm:grid-cols-6 sm:[grid-auto-rows:7rem] md:grid-cols-8 lg:grid-cols-12 lg:[grid-auto-rows:7.5rem]">
<UploadTile v-for="upload in uploads" :key="upload.id" :upload="{ ...upload, ...meta[upload.id] }" />
</div>
<!-- Seiten haben variable Item-Anzahl (Zell-Budget) — Pagination arbeitet
daher mit items-per-page=cells und total=pageCount*cells -->
<Pagination
v-if="pageCount > 1"
v-model:page="page"
:items-per-page="cells"
:total="pageCount * cells"
:sibling-count="1"
show-edges
class="mt-auto pt-4"
>
<PaginationContent v-slot="{ items }">
<PaginationPrevious />
<template v-for="(item, i) in items" :key="i">
<PaginationItem
v-if="item.type === 'page'"
:value="item.value"
:is-active="item.value === page"
>
{{ item.value }}
</PaginationItem>
<PaginationEllipsis v-else />
</template>
<PaginationNext />
</PaginationContent>
</Pagination>
</div>
</template>
+64
View File
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
const { login } = useAuth()
const name = ref('')
const password = ref('')
const errorMessage = ref('')
const loading = ref(false)
async function onSubmit() {
if (loading.value) return
loading.value = true
errorMessage.value = ''
try {
await login(name.value, password.value)
await navigateTo('/')
}
catch (err) {
const e = err as { data?: { statusMessage?: string, message?: string } }
errorMessage.value = e.data?.statusMessage ?? e.data?.message ?? 'Login fehlgeschlagen'
}
finally {
loading.value = false
}
}
</script>
<template>
<div class="mx-auto max-w-sm px-4 lg:px-6">
<Card>
<CardHeader>
<CardTitle>Login</CardTitle>
<CardDescription>Melde dich mit Name und Passwort an.</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-4" @submit.prevent="onSubmit">
<div class="space-y-1.5">
<Label for="name">Name</Label>
<Input id="name" v-model="name" autocomplete="username" required />
</div>
<div class="space-y-1.5">
<Label for="password">Passwort</Label>
<Input id="password" v-model="password" type="password" autocomplete="current-password" required />
</div>
<p v-if="errorMessage" class="text-sm text-destructive">
{{ errorMessage }}
</p>
<Button type="submit" class="w-full" :disabled="loading">
Anmelden
</Button>
<p class="text-center text-sm text-muted-foreground">
Noch kein Konto?
<NuxtLink to="/register" class="text-primary underline">
Registrieren
</NuxtLink>
</p>
</form>
</CardContent>
</Card>
</div>
</template>
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
const { register } = useAuth()
const name = ref('')
const password = ref('')
const email = ref('')
const errorMessage = ref('')
const loading = ref(false)
async function onSubmit() {
if (loading.value) return
loading.value = true
errorMessage.value = ''
try {
await register(name.value, password.value, email.value || undefined)
await navigateTo('/')
}
catch (err) {
const e = err as { data?: { statusMessage?: string, message?: string } }
errorMessage.value = e.data?.statusMessage ?? e.data?.message ?? 'Registrierung fehlgeschlagen'
}
finally {
loading.value = false
}
}
</script>
<template>
<div class="mx-auto max-w-sm px-4 lg:px-6">
<Card>
<CardHeader>
<CardTitle>Registrieren</CardTitle>
<CardDescription>Name und Passwort reichen — E-Mail ist optional.</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-4" @submit.prevent="onSubmit">
<div class="space-y-1.5">
<Label for="name">Name</Label>
<Input id="name" v-model="name" autocomplete="username" required minlength="3" maxlength="50" />
</div>
<div class="space-y-1.5">
<Label for="password">Passwort</Label>
<Input id="password" v-model="password" type="password" autocomplete="new-password" required minlength="6" />
</div>
<div class="space-y-1.5">
<Label for="email">E-Mail (optional)</Label>
<Input id="email" v-model="email" type="email" autocomplete="email" />
</div>
<p v-if="errorMessage" class="text-sm text-destructive">
{{ errorMessage }}
</p>
<Button type="submit" class="w-full" :disabled="loading">
Konto erstellen
</Button>
<p class="text-center text-sm text-muted-foreground">
Schon registriert?
<NuxtLink to="/login" class="text-primary underline">
Login
</NuxtLink>
</p>
</form>
</CardContent>
</Card>
</div>
</template>
+265
View File
@@ -0,0 +1,265 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { toast } from 'vue-sonner'
import { CARD_BORDER_EFFECTS, type CardBorderEffectKey, isCardBorderEffect } from '~~/shared/utils/cardBorderEffects'
import { CARD_EFFECTS, type CardEffectKey } from '~~/shared/utils/cardEffects'
import CardBorderEffect from '@/components/effects/CardBorderEffect.vue'
import CardVignette from '@/components/effects/CardVignette.vue'
import FileDropzone from '@/components/FileDropzone.vue'
import EffectAcidSquares from '@/components/effects/EffectAcidSquares.vue'
import EffectAurora from '@/components/effects/EffectAurora.vue'
import EffectCrtWarp from '@/components/effects/EffectCrtWarp.vue'
import EffectDither from '@/components/effects/EffectDither.vue'
import EffectGradientWaves from '@/components/effects/EffectGradientWaves.vue'
import EffectLetterGlitch from '@/components/effects/EffectLetterGlitch.vue'
import EffectLineWaves from '@/components/effects/EffectLineWaves.vue'
import EffectParticles from '@/components/effects/EffectParticles.vue'
import EffectPlasma from '@/components/effects/EffectPlasma.vue'
import EffectScanner from '@/components/effects/EffectScanner.vue'
import EffectShapeGrid from '@/components/effects/EffectShapeGrid.vue'
import EffectSilk from '@/components/effects/EffectSilk.vue'
import EffectSoftAurora from '@/components/effects/EffectSoftAurora.vue'
import EffectWaves from '@/components/effects/EffectWaves.vue'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { ColorPicker } from '@/components/ui/color-picker'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
const { user, refresh } = useAuth()
const { invalidate } = useUserSummaries()
const color = ref(user.value?.color ?? '#6366f1')
const cardEffect = ref<string>(user.value?.cardEffect ?? 'none')
const cardEffectColor = ref<string>(user.value?.cardEffectColor ?? '#6366f1')
const cardEffectColorEnabled = ref(!!user.value?.cardEffectColor)
const cardVignette = ref(user.value?.cardVignette ?? false)
const cardBorderEffect = ref<string>(user.value?.cardBorderEffect ?? 'none')
const cardBorderEffectColor = ref<string>(user.value?.cardBorderEffectColor ?? '#6366f1')
const cardBorderEffectColorEnabled = ref(!!user.value?.cardBorderEffectColor)
const avatar = ref<File | null>(null)
const saving = ref(false)
const EFFECT_COMPONENTS: Record<CardEffectKey, Component> = {
'gradient-waves': EffectGradientWaves,
'aurora': EffectAurora,
'particles': EffectParticles,
'letter-glitch': EffectLetterGlitch,
'waves': EffectWaves,
'scanner': EffectScanner,
'acid-squares': EffectAcidSquares,
'crt-warp': EffectCrtWarp,
'dither': EffectDither,
'soft-aurora': EffectSoftAurora,
'silk': EffectSilk,
'shape-grid': EffectShapeGrid,
'plasma': EffectPlasma,
'line-waves': EffectLineWaves,
}
const previewEffect = computed(() =>
cardEffect.value !== 'none' && cardEffect.value in EFFECT_COMPONENTS
? EFFECT_COMPONENTS[cardEffect.value as CardEffectKey]
: null,
)
const previewBorder = computed<CardBorderEffectKey | null>(() =>
isCardBorderEffect(cardBorderEffect.value) ? cardBorderEffect.value : null,
)
// Effekt-Farben sind optional — ohne Häkchen fällt der Effekt auf die Namensfarbe
const previewEffectColor = computed(() =>
cardEffectColorEnabled.value ? cardEffectColor.value : color.value,
)
const previewBorderColor = computed(() =>
cardBorderEffectColorEnabled.value ? cardBorderEffectColor.value : color.value,
)
const showPreview = computed(() => previewEffect.value || previewBorder.value)
watch(() => user.value?.color, (c) => {
if (c) color.value = c
})
watch(() => user.value?.cardEffect, (e) => {
cardEffect.value = e ?? 'none'
})
watch(() => user.value?.cardEffectColor, (c) => {
cardEffectColorEnabled.value = !!c
if (c) cardEffectColor.value = c
})
watch(() => user.value?.cardVignette, (v) => {
cardVignette.value = v ?? false
})
watch(() => user.value?.cardBorderEffect, (e) => {
cardBorderEffect.value = e ?? 'none'
})
watch(() => user.value?.cardBorderEffectColor, (c) => {
cardBorderEffectColorEnabled.value = !!c
if (c) cardBorderEffectColor.value = c
})
async function save() {
if (saving.value || !user.value) return
saving.value = true
try {
const formData = new FormData()
formData.append('color', color.value)
formData.append('cardEffect', cardEffect.value === 'none' ? '' : cardEffect.value)
formData.append('cardEffectColor', cardEffectColorEnabled.value ? cardEffectColor.value : '')
formData.append('cardVignette', cardVignette.value ? '1' : '')
formData.append('cardBorderEffect', cardBorderEffect.value === 'none' ? '' : cardBorderEffect.value)
formData.append('cardBorderEffectColor', cardBorderEffectColorEnabled.value ? cardBorderEffectColor.value : '')
if (avatar.value) formData.append('avatar', avatar.value)
await $fetch('/api/users/me', { method: 'POST', body: formData })
await refresh()
// Gecachte Darstellungen (Hover-Card, Profil, Nutzerliste, Uploader-Karte)
// verwerfen, damit die neuen Einstellungen ohne Reload greifen
if (user.value) invalidate(user.value.id)
await refreshNuxtData()
avatar.value = null
toast.success('Einstellungen gespeichert')
}
catch (err) {
const e = err as { data?: { statusMessage?: string } }
toast.error(e.data?.statusMessage ?? 'Speichern fehlgeschlagen')
}
finally {
saving.value = false
}
}
</script>
<template>
<div class="mx-auto w-full max-w-2xl px-4 lg:px-6">
<Card v-if="user">
<CardHeader>
<CardTitle>Einstellungen</CardTitle>
<CardDescription>Name-Farbe und Avatar anpassen.</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-4" @submit.prevent="save">
<div class="space-y-1.5">
<Label>Name-Farbe</Label>
<div class="flex items-center gap-3">
<ColorPicker :value="color" type="hex" @value-change="(val: any) => color = val.hex">
<button
type="button"
class="h-9 w-14 cursor-pointer rounded-md border border-input shadow-sm"
:style="{ backgroundColor: color }"
aria-label="Farbe auswählen"
/>
</ColorPicker>
<span class="text-sm font-medium" :style="{ color }">{{ user.name }}</span>
</div>
</div>
<div class="space-y-1.5">
<Label>Hover-Card-Effekt</Label>
<Select v-model="cardEffect">
<SelectTrigger class="w-full">
<SelectValue placeholder="Kein Effekt" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
Keiner
</SelectItem>
<SelectItem v-for="e in CARD_EFFECTS" :key="e.key" :value="e.key">
{{ e.label }}
</SelectItem>
</SelectContent>
</Select>
<div v-if="cardEffect !== 'none'" class="flex flex-wrap items-center gap-x-5 gap-y-2 pt-1">
<label class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
<Checkbox v-model="cardEffectColorEnabled" />
Eigene Effekt-Farbe
</label>
<ColorPicker v-if="cardEffectColorEnabled" :value="cardEffectColor" type="hex" @value-change="(val: any) => cardEffectColor = val.hex">
<button
type="button"
class="h-7 w-12 cursor-pointer rounded-md border border-input shadow-sm"
:style="{ backgroundColor: cardEffectColor }"
aria-label="Effekt-Farbe auswählen"
/>
</ColorPicker>
<label class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
<Checkbox v-model="cardVignette" />
Vignette (Text lesbar halten)
</label>
</div>
</div>
<div class="space-y-1.5">
<Label>Hover-Card-Border</Label>
<Select v-model="cardBorderEffect">
<SelectTrigger class="w-full">
<SelectValue placeholder="Kein Effekt" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
Keiner
</SelectItem>
<SelectItem v-for="e in CARD_BORDER_EFFECTS" :key="e.key" :value="e.key">
{{ e.label }}
</SelectItem>
</SelectContent>
</Select>
<div v-if="cardBorderEffect !== 'none'" class="flex items-center gap-3 pt-1">
<label class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
<Checkbox v-model="cardBorderEffectColorEnabled" />
Eigene Border-Farbe
</label>
<ColorPicker v-if="cardBorderEffectColorEnabled" :value="cardBorderEffectColor" type="hex" @value-change="(val: any) => cardBorderEffectColor = val.hex">
<button
type="button"
class="h-7 w-12 cursor-pointer rounded-md border border-input shadow-sm"
:style="{ backgroundColor: cardBorderEffectColor }"
aria-label="Border-Farbe auswählen"
/>
</ColorPicker>
</div>
</div>
<div v-if="showPreview" class="space-y-1.5">
<Label>Vorschau</Label>
<div class="relative h-24 overflow-hidden rounded-md border bg-popover">
<component :is="previewEffect" v-if="previewEffect" :color="previewEffectColor" />
<div v-if="previewEffect" class="absolute inset-0 bg-popover/60" />
<CardVignette v-if="previewEffect && cardVignette" />
<span class="absolute inset-0 flex items-center justify-center text-sm font-medium" :style="{ color }">
{{ user.name }}
</span>
<CardBorderEffect v-if="previewBorder" :effect="previewBorder" :color="previewBorderColor" />
</div>
<p class="text-xs text-muted-foreground">
So sieht deine Hover-Card für andere aus.
</p>
</div>
<div class="space-y-1.5">
<Label>Avatar</Label>
<div v-if="user.avatarUrl && !avatar" class="mb-2 flex items-center gap-2">
<NuxtImg
:src="user.avatarUrl"
alt="Aktueller Avatar"
width="48"
height="48"
fit="cover"
class="size-12 rounded-lg"
/>
<span class="text-xs text-muted-foreground">Aktueller Avatar</span>
</div>
<FileDropzone v-model="avatar" accept="image/*" hint="Bilder, max. 5 MB" />
</div>
<Button type="submit" class="w-full" :disabled="saving">
{{ saving ? 'Speichert…' : 'Speichern' }}
</Button>
</form>
</CardContent>
</Card>
<p v-else class="text-center text-muted-foreground">
<NuxtLink to="/login" class="text-primary underline">
Melde dich an
</NuxtLink>, um deine Einstellungen zu ändern.
</p>
</div>
</template>
File diff suppressed because it is too large Load Diff
+364
View File
@@ -0,0 +1,364 @@
<script setup lang="ts">
import UploadTile from '@/components/UploadTile.vue'
import CardBackgroundEffect from '@/components/effects/CardBackgroundEffect.vue'
import CardBorderEffect from '@/components/effects/CardBorderEffect.vue'
import CardVignette from '@/components/effects/CardVignette.vue'
import { isCardEffect } from '~~/shared/utils/cardEffects'
import { isCardBorderEffect } from '~~/shared/utils/cardBorderEffects'
import type { CommentAttachment } from '~~/shared/utils/attachments'
import { Card, CardContent } from '@/components/ui/card'
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination'
interface ProfileUpload {
id: string
type: 'image' | 'video' | 'audio' | 'youtube' | 'twitch' | 'link'
url: string
thumbUrl: string | null
userName: string
userColor: string | null
userAvatarUrl: string | null
commentCount: number
createdAt: string
}
interface UploadsList {
items: ProfileUpload[]
total: number
pageCount: number
}
interface UserComment {
id: number
content: string
createdAt: string
uploadId: string
uploadThumbUrl: string | null
attachments?: CommentAttachment[]
}
interface CommentsList {
items: UserComment[]
total: number
pageCount: number
}
interface UserProfile {
user: {
id: number
name: string
color: string | null
avatarUrl: string | null
cardEffect: string | null
cardEffectColor: string | null
cardVignette: boolean
cardBorderEffect: string | null
cardBorderEffectColor: string | null
createdAt: string
}
stats: {
uploads: number
comments: number
}
}
const route = useRoute()
const router = useRouter()
const userId = computed(() => route.params.id as string)
const { data, error } = await useFetch<UserProfile>(() => `/api/users/${userId.value}`)
// Upload-/Kommentar-Listen: viewport-abhängige Pagination läuft clientseitig
// (wie Mainseite). Die Sektions-Überschrift dient beiden Ansichten als Anker.
const view = ref<'uploads' | 'comments'>('uploads')
const sectionHeadEl = ref<HTMLElement>()
const { cells, cols } = usePageSize(sectionHeadEl, { rowHeights: [5, 5.5, 6], gapBelowAnchorPx: 12 })
// Kommentar-Zeilen sind ~4.3rem hoch ohne Grid-Gap; cells/cols -> Zeilenanzahl
const { cells: commentCells, cols: commentCols } = usePageSize(sectionHeadEl, { rowHeights: [4.5, 4.5, 4.5], gapBelowAnchorPx: 12 })
const commentPerPage = computed(() => Math.max(1, Math.floor(commentCells.value / commentCols.value)))
const { uniform } = useGridMode()
const page = computed<number>({
get: () => Math.max(1, Math.floor(Number(route.query.page)) || 1),
set: (v) => {
const query = { ...route.query }
if (v > 1) query.page = String(v)
else delete query.page
router.replace({ query })
},
})
const cpage = computed<number>({
get: () => Math.max(1, Math.floor(Number(route.query.cpage)) || 1),
set: (v) => {
const query = { ...route.query }
if (v > 1) query.cpage = String(v)
else delete query.cpage
router.replace({ query })
},
})
const uploadsUrl = computed(() => {
const params = new URLSearchParams({
page: String(page.value),
cells: String(cells.value),
cols: String(cols.value),
mode: uniform.value ? 'uniform' : 'bento',
})
return `/api/users/${userId.value}/uploads?${params.toString()}`
})
const { data: uploadsResult, pending: uploadsPending } = await useFetch<UploadsList>(uploadsUrl, {
key: computed(() => `user-uploads-${userId.value}`),
server: false,
watch: [uploadsUrl],
})
const uploads = computed(() => uploadsResult.value?.items ?? [])
const uploadsPageCount = computed(() => uploadsResult.value?.pageCount ?? 0)
// Items können durch neue Uploads/Löschungen/Live-Updates weniger Seiten werden
watch(uploadsPageCount, (count) => {
if (count > 0 && page.value > count) page.value = count
})
const comments = ref<CommentsList | null>(null)
const commentsPending = ref(false)
let commentReq = 0
async function loadComments() {
const req = ++commentReq
commentsPending.value = true
try {
const res = await $fetch<CommentsList>(`/api/users/${userId.value}/comments`, {
query: { page: cpage.value, perPage: commentPerPage.value },
})
if (req === commentReq) comments.value = res
}
finally {
if (req === commentReq) commentsPending.value = false
}
}
// Lazy laden + bei Seiten-/Viewport-Wechsel neu laden (nur in Kommentar-Ansicht)
watchEffect(() => {
if (!import.meta.client || view.value !== 'comments') return
void loadComments()
})
function showComments() {
view.value = 'comments'
}
watch(() => comments.value?.pageCount, (count) => {
if (count && cpage.value > count) cpage.value = count
})
// Originale vorladen, damit die View-Transition das echte Bild morpht statt eines leeren Platzhalters
watch(uploads, (list) => {
if (!import.meta.client) return
for (const u of list ?? []) {
if (u.type === 'image' && u.url) {
const img = new Image()
img.src = u.url
}
}
}, { immediate: true })
const initials = computed(() => data.value?.user.name.slice(0, 2).toUpperCase() ?? '')
// Profil-Karte bekommt die gewählten Effekte des Nutzers (wie Hover-Card/Userliste)
const profileCardEffect = computed(() => {
const k = data.value?.user.cardEffect
return k && isCardEffect(k) ? k : null
})
const profileBorderEffect = computed(() => {
const k = data.value?.user.cardBorderEffect
return k && isCardBorderEffect(k) ? k : null
})
const profileColor = computed(() => data.value?.user.color ?? '#6366f1')
const profileEffectColor = computed(() => data.value?.user.cardEffectColor ?? profileColor.value)
const profileBorderColor = computed(() => data.value?.user.cardBorderEffectColor ?? profileColor.value)
const joinDate = computed(() => data.value
? new Intl.DateTimeFormat('de-DE', { dateStyle: 'long' }).format(new Date(data.value.user.createdAt))
: '')
const timeAgo = useTimeAgo()
</script>
<template>
<div class="px-4 lg:px-6">
<p v-if="error" class="text-muted-foreground">
Nutzer nicht gefunden.
</p>
<template v-else-if="data">
<Card class="relative isolate max-w-2xl" :class="profileBorderEffect && 'ring-0'">
<div v-if="profileCardEffect" class="pointer-events-none absolute inset-0 -z-10 rounded-[inherit]">
<CardBackgroundEffect :effect="profileCardEffect" :color="profileEffectColor" />
<div class="absolute inset-0 bg-card/70" />
<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">
<NuxtImg
v-if="data.user.avatarUrl"
:src="data.user.avatarUrl"
:alt="data.user.name"
width="96"
height="96"
fit="cover"
class="size-24 shrink-0 rounded-lg"
/>
<span
v-else
class="flex size-24 shrink-0 items-center justify-center rounded-lg text-3xl font-medium"
:style="data.user.color ? { backgroundColor: data.user.color, color: '#fff' } : { backgroundColor: 'var(--color-muted)' }"
>{{ initials }}</span>
<div class="min-w-0">
<h1
class="truncate text-2xl font-semibold"
:style="data.user.color ? { color: data.user.color } : undefined"
>
{{ data.user.name }}
</h1>
<p class="text-sm text-muted-foreground">
ID {{ data.user.id }}
</p>
<p class="mt-1 flex items-center gap-1.5 text-sm text-muted-foreground">
<Icon name="ph:calendar-fill" class="size-3.5" />
Mitglied seit {{ joinDate }}
</p>
<div class="mt-3 flex items-center gap-4 text-sm">
<button
type="button"
class="flex items-center gap-1.5 rounded-sm transition-colors hover:text-foreground"
:class="view === 'uploads' ? 'text-foreground underline underline-offset-4' : 'text-muted-foreground'"
@click="view = 'uploads'"
>
<Icon name="ph:image-fill" class="size-4" />
{{ data.stats.uploads }} Uploads
</button>
<button
type="button"
class="flex items-center gap-1.5 rounded-sm transition-colors hover:text-foreground"
:class="view === 'comments' ? 'text-foreground underline underline-offset-4' : 'text-muted-foreground'"
@click="showComments"
>
<Icon name="ph:chat-fill" class="size-4" />
{{ data.stats.comments }} Kommentare
</button>
</div>
</div>
</CardContent>
</Card>
<section class="mt-6">
<h2 ref="sectionHeadEl" class="mb-3 font-semibold">
{{ view === 'uploads' ? 'Uploads' : 'Kommentare' }}
</h2>
<template v-if="view === 'uploads'">
<p v-if="uploadsPending" class="text-sm text-muted-foreground">
Lade Uploads…
</p>
<p v-else-if="!uploads.length" class="text-sm text-muted-foreground">
Noch keine Uploads.
</p>
<div v-else class="grid grid-flow-dense grid-cols-4 gap-2 [grid-auto-rows:5rem] sm:grid-cols-6 sm:[grid-auto-rows:5.5rem] md:grid-cols-8 lg:grid-cols-12 lg:[grid-auto-rows:6rem]">
<UploadTile v-for="upload in uploads" :key="upload.id" :upload="upload" />
</div>
<!-- Seiten haben variable Item-Anzahl (Zell-Budget) — Pagination
arbeitet mit items-per-page=cells und total=pageCount*cells -->
<Pagination
v-if="uploadsPageCount > 1"
v-model:page="page"
:items-per-page="cells"
:total="uploadsPageCount * cells"
:sibling-count="1"
show-edges
class="mt-4"
>
<PaginationContent v-slot="{ items }">
<PaginationPrevious />
<template v-for="(item, i) in items" :key="i">
<PaginationItem
v-if="item.type === 'page'"
:value="item.value"
:is-active="item.value === page"
>
{{ item.value }}
</PaginationItem>
<PaginationEllipsis v-else />
</template>
<PaginationNext />
</PaginationContent>
</Pagination>
</template>
<template v-else>
<p v-if="commentsPending" class="text-sm text-muted-foreground">
Lade Kommentare…
</p>
<p v-else-if="!comments?.items.length" class="text-sm text-muted-foreground">
Noch keine Kommentare.
</p>
<div v-else class="flex max-w-2xl flex-col divide-y rounded-md border">
<NuxtLink
v-for="comment in comments.items"
:key="comment.id"
:to="`/thread/${comment.uploadId}#comment-${comment.id}`"
class="flex items-center gap-3 px-3 py-2.5 text-sm transition-colors hover:bg-accent"
>
<NuxtImg
v-if="comment.uploadThumbUrl"
:src="comment.uploadThumbUrl"
alt=""
width="48"
height="48"
fit="cover"
class="size-12 shrink-0 rounded-sm"
/>
<span v-else class="flex size-12 shrink-0 items-center justify-center rounded-sm bg-muted">
<Icon name="ph:chat-fill" class="size-5 text-muted-foreground" />
</span>
<CommentContent :content="comment.content" compact class="min-w-0 flex-1 line-clamp-2 break-words" />
<CommentAttachments v-if="comment.attachments?.length" :attachments="comment.attachments" compact class="shrink-0" />
<span class="shrink-0 text-xs text-muted-foreground">{{ timeAgo(comment.createdAt) }}</span>
</NuxtLink>
</div>
<Pagination
v-if="comments && comments.pageCount > 1"
v-model:page="cpage"
:items-per-page="commentPerPage"
:total="comments.total"
:sibling-count="1"
show-edges
class="mt-4"
>
<PaginationContent v-slot="{ items }">
<PaginationPrevious />
<template v-for="(item, i) in items" :key="i">
<PaginationItem
v-if="item.type === 'page'"
:value="item.value"
:is-active="item.value === cpage"
>
{{ item.value }}
</PaginationItem>
<PaginationEllipsis v-else />
</template>
<PaginationNext />
</PaginationContent>
</Pagination>
</template>
</section>
</template>
</div>
</template>
+113
View File
@@ -0,0 +1,113 @@
<script setup lang="ts">
import type { CardBorderEffectKey } from '~~/shared/utils/cardBorderEffects'
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 { isCardBorderEffect } from '~~/shared/utils/cardBorderEffects'
import { isCardEffect } from '~~/shared/utils/cardEffects'
import { cn } from '@/lib/utils'
interface ListedUser {
id: number
name: string
color: string | null
avatarUrl: string | null
cardEffect: string | null
cardEffectColor: string | null
cardVignette: boolean
cardBorderEffect: string | null
cardBorderEffectColor: string | null
createdAt: string
stats: {
uploads: number
comments: number
}
}
const { data: users } = await useFetch<ListedUser[]>('/api/users', {
key: 'users-list',
default: () => [],
})
function cardEffect(user: ListedUser): CardEffectKey | null {
return user.cardEffect && isCardEffect(user.cardEffect) ? user.cardEffect : null
}
function cardBorderEffect(user: ListedUser): CardBorderEffectKey | null {
return user.cardBorderEffect && isCardBorderEffect(user.cardBorderEffect) ? user.cardBorderEffect : null
}
const GRID_COLS = 'grid-cols-[2.5rem_minmax(0,1fr)_3rem_2.5rem_2.5rem] sm:grid-cols-[2.5rem_minmax(0,1fr)_3.5rem_4.5rem_4.5rem_10rem]'
function joinDate(value: string) {
return new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' }).format(new Date(value))
}
</script>
<template>
<div class="px-4 lg:px-6">
<h1 class="mb-4 text-xl font-semibold">
Benutzer
</h1>
<p v-if="!users.length" class="text-sm text-muted-foreground">
Noch keine Benutzer.
</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)">
<span />
<span>Name</span>
<span class="text-right">ID</span>
<span class="hidden text-right sm:block">Uploads</span>
<span class="hidden text-right sm:block">Kommentare</span>
<span class="hidden text-right sm:block">Dabei seit</span>
</div>
<div class="mt-1 flex flex-col gap-2">
<NuxtLink
v-for="user in 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',
)"
>
<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'" />
<div class="absolute inset-0 bg-background/60" />
<CardVignette v-if="user.cardVignette" surface="var(--color-background)" />
</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)">
<NuxtImg
v-if="user.avatarUrl"
:src="user.avatarUrl"
:alt="user.name"
width="40"
height="40"
fit="cover"
class="size-10 shrink-0 rounded-sm"
/>
<span
v-else
class="flex size-10 shrink-0 items-center justify-center rounded-sm text-xs font-medium"
:style="user.color ? { backgroundColor: user.color, color: '#fff' } : { backgroundColor: 'var(--color-muted)' }"
>{{ user.name.slice(0, 2).toUpperCase() }}</span>
<span
class="truncate font-medium"
:style="user.color ? { color: user.color } : undefined"
>{{ user.name }}</span>
<span class="text-right text-sm text-muted-foreground">{{ user.id }}</span>
<span class="hidden text-right text-sm sm:block">{{ user.stats.uploads }}</span>
<span class="hidden text-right text-sm sm:block">{{ user.stats.comments }}</span>
<span class="hidden text-right text-sm text-muted-foreground sm:block">{{ joinDate(user.createdAt) }}</span>
</div>
</NuxtLink>
</div>
</div>
</div>
</template>