Files
BRETT/app/components/CommentAttachments.vue
FliesentischKingandDevin fe9e723c72 Media attachment cards with popover players and shared volume
- 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>
2026-10-04 03:06:11 +02:00

174 lines
7.9 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>