forked from BRETT/BRETT
- Audio/video attachments render as media cards (cover or icon, title/artist/size) instead of plain file chips; image attachments use 3:2 thumbnails matching the audio card height - Players open in a Popover (autoplay on open) with a dimming overlay; AudioPlayerDialog/VideoPlayerDialog removed - New useMediaVolume composable shares volume/mute between all audio players and Plyr via the 4brett-player storage key - Player buttons and Plyr controls follow the theme border radius - Portrait videos: aspect is applied even when metadata loaded before hydration, and the raw video is height-capped pre-JS Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
174 lines
7.9 KiB
Vue
174 lines
7.9 KiB
Vue
<script setup lang="ts">
|
||
import ImageLightbox from '@/components/ImageLightbox.vue'
|
||
import VideoPlayer from '@/components/VideoPlayer.vue'
|
||
import VoiceMessagePlayer from '@/components/VoiceMessagePlayer.vue'
|
||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||
import { Separator } from '@/components/ui/separator'
|
||
import { formatFileSize, isAudioAttachment, isImageAttachment, isVideoAttachment, isVoiceAttachment } from '~~/shared/utils/attachments'
|
||
import type { CommentAttachment } from '~~/shared/utils/attachments'
|
||
|
||
const props = defineProps<{
|
||
attachments: CommentAttachment[]
|
||
compact?: boolean
|
||
}>()
|
||
|
||
const voices = computed(() => props.attachments.filter(isVoiceAttachment))
|
||
const images = computed(() => props.attachments.filter(a => isImageAttachment(a) && !isVoiceAttachment(a)))
|
||
const audios = computed(() => props.attachments.filter(a => isAudioAttachment(a) && !isImageAttachment(a) && !isVoiceAttachment(a)))
|
||
const videos = computed(() => props.attachments.filter(a => isVideoAttachment(a) && !isVoiceAttachment(a)))
|
||
const files = computed(() => props.attachments.filter(a => !isImageAttachment(a) && !isAudioAttachment(a) && !isVideoAttachment(a) && !isVoiceAttachment(a)))
|
||
const hasFileAttachments = computed(() => images.value.length + audios.value.length + videos.value.length + files.value.length > 0)
|
||
|
||
const lightboxSrc = ref<string | null>(null)
|
||
const lightboxAlt = ref('')
|
||
// ID des Anhangs, dessen Player-Popover offen ist (steuert auch das Dim-Overlay)
|
||
const openAtt = ref<number | null>(null)
|
||
|
||
function audioMetaLine(a: CommentAttachment) {
|
||
const m = a.meta
|
||
if (!m) return null
|
||
const parts = [m.artist, m.title].filter(Boolean)
|
||
return parts.length ? parts.join(' – ') : (m.album || null)
|
||
}
|
||
|
||
function fileIcon(a: CommentAttachment) {
|
||
const mime = a.mimeType ?? ''
|
||
const name = a.name.toLowerCase()
|
||
if (mime.startsWith('video/')) return 'ph:file-video-fill'
|
||
if (mime === 'application/pdf' || /\.(txt|md|pdf|docx?|odt)$/.test(name)) return 'ph:file-text-fill'
|
||
if (/\.(zip|rar|7z|tar|gz|bz2|xz)$/.test(name) || /zip|compressed|x-tar|gzip/.test(mime)) return 'ph:file-archive-fill'
|
||
return 'ph:file-fill'
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<!-- Kompakt-Modus (Sidebar): nur ein Hinweis-Chip -->
|
||
<span
|
||
v-if="compact && attachments.length"
|
||
class="mx-0.5 inline-flex items-center gap-1 rounded-sm bg-muted px-1.5 py-0.5 align-middle text-xs text-muted-foreground"
|
||
>
|
||
<Icon name="ph:folder-plus-fill" class="size-3.5" />
|
||
{{ attachments.length }}
|
||
</span>
|
||
|
||
<div v-else-if="attachments.length" class="mt-2.5 space-y-2.5">
|
||
<div v-if="openAtt !== null" class="animate-in fade-in fixed inset-0 z-40 bg-black/50 duration-150" />
|
||
<!-- Sprachnachrichten gehören direkt zum Kommentar, nicht in den Anhang-Bereich -->
|
||
<div v-if="voices.length" class="flex flex-col gap-1.5">
|
||
<VoiceMessagePlayer
|
||
v-for="a in voices"
|
||
:key="a.id"
|
||
:src="a.url"
|
||
:name="a.name"
|
||
/>
|
||
</div>
|
||
<template v-if="hasFileAttachments">
|
||
<div class="mb-1.5 flex items-center gap-2">
|
||
<span class="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">{{ $t('comments.attachments') }}</span>
|
||
<Separator class="flex-1" />
|
||
</div>
|
||
<div class="flex flex-wrap items-center gap-1.5">
|
||
<button
|
||
v-for="a in images"
|
||
:key="a.id"
|
||
type="button"
|
||
class="group relative aspect-[3/2] w-36 cursor-pointer overflow-hidden rounded-md border bg-muted transition-colors hover:border-primary"
|
||
:title="a.name"
|
||
@click.stop="lightboxSrc = a.url; lightboxAlt = a.name"
|
||
>
|
||
<NuxtImg
|
||
:src="a.url"
|
||
:alt="a.name"
|
||
width="288"
|
||
height="192"
|
||
fit="cover"
|
||
loading="lazy"
|
||
class="size-full object-cover"
|
||
/>
|
||
</button>
|
||
<Popover
|
||
v-for="a in audios"
|
||
:key="a.id"
|
||
:open="openAtt === a.id"
|
||
@update:open="v => openAtt = v ? a.id : null"
|
||
>
|
||
<PopoverTrigger as-child>
|
||
<button
|
||
type="button"
|
||
class="flex h-24 w-52 cursor-pointer items-stretch overflow-hidden rounded-md border bg-muted/50 text-left text-xs transition-colors hover:border-primary hover:bg-accent data-[state=open]:border-primary data-[state=open]:bg-accent"
|
||
:title="$t('comments.playAttachment', { name: a.name })"
|
||
@click.stop
|
||
>
|
||
<img v-if="a.cover" :src="a.cover" :alt="a.name" class="h-full w-20 shrink-0 object-cover">
|
||
<div v-else class="flex w-14 shrink-0 items-center justify-center bg-muted">
|
||
<Icon name="ph:file-audio-fill" class="size-5 text-muted-foreground" />
|
||
</div>
|
||
<div class="flex min-w-0 flex-1 flex-col justify-center gap-0.5 p-2.5">
|
||
<span class="line-clamp-2 break-words font-medium leading-snug">{{ a.meta?.title ?? a.name }}</span>
|
||
<span v-if="a.meta?.artist" class="truncate text-muted-foreground">{{ a.meta.artist }}</span>
|
||
<span v-if="formatFileSize(a.size)" class="text-muted-foreground">{{ formatFileSize(a.size) }}</span>
|
||
</div>
|
||
<Icon name="ph:play-fill" class="mx-2.5 size-4 shrink-0 self-center text-muted-foreground" />
|
||
</button>
|
||
</PopoverTrigger>
|
||
<PopoverContent class="w-96 gap-3 p-3" @click.stop>
|
||
<img v-if="a.cover" :src="a.cover" :alt="a.name" class="aspect-square w-full rounded-md border bg-muted object-cover">
|
||
<div v-else class="flex aspect-square w-full items-center justify-center rounded-md border bg-muted">
|
||
<Icon name="ph:music-note-fill" class="size-16 text-muted-foreground" />
|
||
</div>
|
||
<span v-if="audioMetaLine(a)" class="truncate text-sm text-muted-foreground">{{ audioMetaLine(a) }}</span>
|
||
<VoiceMessagePlayer :src="a.url" :name="a.name" autoplay />
|
||
</PopoverContent>
|
||
</Popover>
|
||
<Popover
|
||
v-for="a in videos"
|
||
:key="a.id"
|
||
:open="openAtt === a.id"
|
||
@update:open="v => openAtt = v ? a.id : null"
|
||
>
|
||
<PopoverTrigger as-child>
|
||
<button
|
||
type="button"
|
||
class="flex h-24 w-52 cursor-pointer items-stretch overflow-hidden rounded-md border bg-muted/50 text-left text-xs transition-colors hover:border-primary hover:bg-accent data-[state=open]:border-primary data-[state=open]:bg-accent"
|
||
:title="$t('comments.playAttachment', { name: a.name })"
|
||
@click.stop
|
||
>
|
||
<img v-if="a.cover" :src="a.cover" :alt="a.name" class="h-full w-20 shrink-0 bg-muted object-cover">
|
||
<video v-else :src="a.url" preload="metadata" muted playsinline class="h-full w-20 shrink-0 bg-muted object-cover" />
|
||
<div class="flex min-w-0 flex-1 flex-col justify-center gap-0.5 p-2.5">
|
||
<span class="line-clamp-2 break-words font-medium leading-snug">{{ a.name }}</span>
|
||
<span v-if="formatFileSize(a.size)" class="text-muted-foreground">{{ formatFileSize(a.size) }}</span>
|
||
</div>
|
||
<Icon name="ph:play-fill" class="mx-2.5 size-4 shrink-0 self-center text-muted-foreground" />
|
||
</button>
|
||
</PopoverTrigger>
|
||
<PopoverContent class="w-[24rem] p-2 sm:w-[36rem]" @click.stop>
|
||
<VideoPlayer :src="a.url" autoplay />
|
||
</PopoverContent>
|
||
</Popover>
|
||
<a
|
||
v-for="a in files"
|
||
:key="a.id"
|
||
:href="a.url"
|
||
:download="a.name"
|
||
class="flex max-w-48 items-center gap-1.5 rounded-md border bg-muted/50 px-2 py-1 text-xs transition-colors hover:border-primary hover:bg-accent"
|
||
:title="a.name"
|
||
@click.stop
|
||
>
|
||
<Icon :name="fileIcon(a)" class="size-3.5 shrink-0 text-muted-foreground" />
|
||
<span class="truncate">{{ a.name }}</span>
|
||
<span v-if="formatFileSize(a.size)" class="shrink-0 text-muted-foreground">{{ formatFileSize(a.size) }}</span>
|
||
<Icon name="ph:download-fill" class="size-3.5 shrink-0 text-muted-foreground" />
|
||
</a>
|
||
|
||
<ImageLightbox
|
||
v-if="lightboxSrc"
|
||
:src="lightboxSrc"
|
||
:alt="lightboxAlt"
|
||
@close="lightboxSrc = null"
|
||
/>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</template>
|