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