forked from BRETT/BRETT
Initial Commit
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user