forked from BRETT/BRETT
- Select mode in the filter row (upload.delete permission): tiles toggle selection in the capture phase, a floating action bar offers select-all and delete with confirmation popover - New POST /api/uploads/bulk-delete endpoint (validated, deduped, max 200 ids) removing rows, files and attachment data - upload-deleted broadcast on bulk and single delete; other clients refresh the grid and drop matching notifications - Also: skeleton tile grid as loading state, dropzone logic moved to the layout-level GlobalDropZone, obsolete ytimg preconnect removed Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
198 lines
7.1 KiB
Vue
198 lines
7.1 KiB
Vue
<script setup lang="ts">
|
|
import { toast } from 'vue-sonner'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
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 = withDefaults(defineProps<{ upload: TileUpload, selectable?: boolean, selected?: boolean }>(), {
|
|
selectable: false,
|
|
selected: false,
|
|
})
|
|
const emit = defineEmits<{ select: [] }>()
|
|
const { t } = useI18n()
|
|
|
|
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
|
|
// Capture-Phase: läuft vor NuxtLinks eigenem Klick-Handler — preventDefault +
|
|
// stopImmediatePropagation verhindert die Navigation sicher in allen Browsern
|
|
function captureClick(e: MouseEvent) {
|
|
if (!props.selectable) return
|
|
e.preventDefault()
|
|
e.stopImmediatePropagation()
|
|
emit('select')
|
|
}
|
|
|
|
async function openUpload(e: MouseEvent) {
|
|
if (props.selectable) return
|
|
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(t('upload.threadLoadFailed'))
|
|
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.capture="captureClick"
|
|
@click="openUpload"
|
|
>
|
|
<div
|
|
:class="cn('upload-tile-media relative h-full w-full overflow-hidden rounded-md', selected ? 'ring-4 ring-accent' : hasNotification && 'ring-4 ring-primary')"
|
|
: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="selectable"
|
|
:class="cn('absolute top-1.5 left-1.5 z-10 flex size-6 items-center justify-center rounded-full border shadow', selected ? 'border-accent bg-accent text-accent-foreground' : 'border-muted-foreground/40 bg-background/60')"
|
|
>
|
|
<Icon v-if="selected" name="ph:check-bold" class="size-3.5" />
|
|
</span>
|
|
<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>
|