forked from BRETT/BRETT
366 lines
13 KiB
Vue
366 lines
13 KiB
Vue
<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 { locale } = useI18n()
|
|
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(locale.value, { 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">
|
|
{{ $t('misc.userNotFound') }}
|
|
</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" />
|
|
{{ $t('misc.memberSince', { date: 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 }} {{ $t('misc.uploadsCol') }}
|
|
</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 }} {{ $t('misc.commentsCol') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<section class="mt-6">
|
|
<h2 ref="sectionHeadEl" class="mb-3 font-semibold">
|
|
{{ view === 'uploads' ? $t('misc.uploadsCol') : $t('misc.commentsCol') }}
|
|
</h2>
|
|
<template v-if="view === 'uploads'">
|
|
<p v-if="uploadsPending" class="text-sm text-muted-foreground">
|
|
{{ $t('grid.loading') }}
|
|
</p>
|
|
<p v-else-if="!uploads.length" class="text-sm text-muted-foreground">
|
|
{{ $t('misc.noUploads') }}
|
|
</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">
|
|
{{ $t('comments.loading') }}
|
|
</p>
|
|
<p v-else-if="!comments?.items.length" class="text-sm text-muted-foreground">
|
|
{{ $t('comments.none') }}
|
|
</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>
|