Files
BRETT/app/components/UploadTile.vue
T
2026-10-03 20:07:49 +02:00

175 lines
6.1 KiB
Vue

<script setup lang="ts">
import { toast } from 'vue-sonner'
interface TileUpload {
id: string
type: 'image' | 'video' | 'audio' | 'youtube' | 'twitch' | 'link'
url: string
thumbUrl?: string | null
userId?: number
userName?: string
userColor?: string | null
userAvatarUrl?: string | null
commentCount?: number
}
const props = defineProps<{ upload: TileUpload }>()
const { unreadUploadIds } = useNotifications()
const { uniform } = useGridMode()
const lastViewedId = useLastViewedUpload()
const pendingMedia = usePendingUploadMedia()
const hasNotification = computed(() => unreadUploadIds.value.has(props.upload.id))
// LQIP: winzige geblurrede Variante sofort, echtes Thumb fadet nach.
// Nur für Quellen, die IPX verarbeiten kann (lokal oder gewhitelistete Domains)
const thumbSrc = computed(() => props.upload.thumbUrl ?? (props.upload.type === 'image' ? props.upload.url : null))
const canPlaceholder = computed(() => {
const s = thumbSrc.value
return !!s && (s.startsWith('/') || s.startsWith('https://i.ytimg.com/') || s.startsWith('https://static-cdn.jtvnw.net/'))
})
const indicator = useLoadingIndicator()
const router = useRouter()
const navigating = ref(false)
const spanClass = computed(() => {
if (uniform.value) return ''
const count = props.upload.commentCount ?? 0
if (count >= 7) return 'col-span-2 row-span-2 sm:col-span-3 sm:row-span-3'
if (count >= 1) return 'col-span-2 row-span-2'
return ''
})
// Klick auf ein Thumbnail:
// 1. Ladeindikator oben sofort starten
// 2. Thread-Daten + Bild/Video vorladen (min. 600 ms, damit der Indikator sichtbar läuft)
// 3. Wenn geladen: viewTransitionName setzen und via startViewTransition navigieren
async function openUpload(e: MouseEvent) {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
e.preventDefault()
if (navigating.value) return
navigating.value = true
indicator.start()
// Der angeklickte Tile bekommt sofort den view-transition-name — der NuxtLink
// navigiert eigenständig, also muss der Name gesetzt sein, bevor der Browser
// den alten Zustand für die Transition fotografiert
lastViewedId.value = props.upload.id
// Bekannte Mediendaten, damit die Thread-Seite sofort ein benanntes
// Element rendern kann (View-Transition-Ziel vor Eintreffen der Daten)
pendingMedia.value = {
id: props.upload.id,
type: props.upload.type,
url: props.upload.url,
thumbUrl: props.upload.thumbUrl ?? null,
}
try {
const threadFetch = $fetch(`/api/uploads/${props.upload.id}`)
const mediaPreload = (async () => {
if (props.upload.type === 'image' && props.upload.url) {
const img = new Image()
img.src = props.upload.url
try {
await Promise.race([img.decode(), new Promise(r => setTimeout(r, 2500))])
}
catch {
// ignore
}
}
else if (props.upload.type === 'video' && props.upload.url) {
await new Promise<void>((resolve) => {
const v = document.createElement('video')
v.preload = 'auto'
v.muted = true
v.onloadeddata = () => resolve()
v.onerror = () => resolve()
v.src = props.upload.url
setTimeout(resolve, 2500)
})
}
})()
const minDelay = new Promise(r => setTimeout(r, 600))
await Promise.all([threadFetch, mediaPreload, minDelay])
}
catch {
indicator.finish({ error: true })
navigating.value = false
toast.error('Thread konnte nicht geladen werden')
return
}
// Die eigentliche Navigation macht der NuxtLink bereits beim Klick —
// dieser Push ist nur noch Fallback und bei gleicher Route ein No-Op
await router.push(`/thread/${props.upload.id}`)
navigating.value = false
}
</script>
<template>
<NuxtLink
:to="`/thread/${upload.id}`"
class="upload-tile group block"
:class="[spanClass, navigating ? 'opacity-80' : '']"
@click="openUpload"
>
<div
class="upload-tile-media relative h-full w-full overflow-hidden rounded-md"
:style="lastViewedId === upload.id ? 'view-transition-name: upload-media' : undefined"
>
<NuxtImg
v-if="thumbSrc"
:src="thumbSrc"
:alt="upload.userName ?? 'Upload'"
width="400"
height="400"
fit="cover"
loading="lazy"
:placeholder="canPlaceholder || undefined"
class="h-full w-full object-cover"
/>
<video
v-else-if="upload.type === 'video'"
:src="upload.url"
preload="metadata"
muted
playsinline
class="h-full w-full object-cover"
/>
<div v-else class="flex h-full w-full items-center justify-center bg-muted">
<Icon name="ph:music-note-fill" v-if="upload.type === 'audio'" class="size-8 text-muted-foreground" />
<Icon name="ph:link-fill" v-else class="size-8 text-muted-foreground" />
</div>
<span
v-if="hasNotification"
class="absolute top-1.5 right-1.5 z-10 flex size-6 items-center justify-center rounded-full bg-accent text-accent-foreground shadow"
>
<Icon name="ph:bell-fill" class="size-3.5" />
</span>
<div class="absolute inset-0 bg-primary/30 opacity-0 transition-opacity group-hover:opacity-100" />
<div class="absolute inset-0 flex items-end bg-gradient-to-t from-black/70 via-black/20 to-transparent p-2 opacity-0 transition-opacity group-hover:opacity-100">
<div class="flex w-full items-end justify-between gap-2 text-xs text-white">
<UserHoverCard
v-if="upload.userId != null"
:user-id="upload.userId"
:user-name="upload.userName ?? ''"
:user-color="upload.userColor"
:user-avatar-url="upload.userAvatarUrl"
class="min-w-0"
>
<span class="truncate">{{ upload.userName }}</span>
</UserHoverCard>
<span v-else class="truncate">{{ upload.userName }}</span>
<span v-if="upload.commentCount !== undefined" class="flex shrink-0 items-center gap-1">
<Icon name="ph:chat-fill" class="size-3.5" />
{{ upload.commentCount }}
</span>
</div>
</div>
</div>
</NuxtLink>
</template>