Files
FliesentischKingandDevin 456776ea68 Shorten thread route from /thread to /t
All internal links (tiles, prev/next nav, comments, notifications,
breadcrumb, dropzone route check) point to /t/<id>. A global route
middleware permanently redirects old /thread/<id> URLs including
query string and comment anchor.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-04 15:24:32 +02:00

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="`/t/${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>