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