Render audio attachments as tiles with inline popover player

This commit is contained in:
Abhinav Raut
2026-05-14 17:04:35 +05:30
parent 534771e94e
commit 475667e9e8
2 changed files with 68 additions and 67 deletions
@@ -1,69 +1,78 @@
<template>
<div class="flex items-center group text-left">
<div
class="relative w-36 h-28 rounded border overflow-hidden cursor-pointer transition-colors"
:class="
isImage
? ''
: 'flex flex-col items-center justify-between bg-muted/40 hover:bg-muted p-3'
"
@click="onClick"
>
<template v-if="isImage">
<img
:src="getThumbFilepath(attachment.url)"
:alt="attachment.name"
class="w-full h-full object-cover"
/>
<Popover :open="showAudio" @update:open="showAudio = $event">
<PopoverTrigger as-child>
<div
class="absolute inset-x-0 top-0 flex items-start justify-between gap-2 px-2 pt-1.5 pb-5 bg-gradient-to-b from-black/75 via-black/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
class="relative w-36 h-28 rounded border overflow-hidden cursor-pointer transition-colors"
:class="
isImage
? ''
: 'flex flex-col items-center justify-between bg-muted/40 hover:bg-muted p-3'
"
@click="onClick"
>
<div class="min-w-0 flex-1 text-white image-meta">
<p class="font-medium text-xs truncate">{{ shortName(attachment.name) }}</p>
<p class="text-[10px] opacity-90">{{ formatBytes(attachment.size) }}</p>
</div>
<template v-if="isImage">
<img
:src="getThumbFilepath(attachment.url)"
:alt="attachment.name"
class="w-full h-full object-cover"
/>
<div
class="absolute inset-x-0 top-0 flex items-start justify-between gap-2 px-2 pt-1.5 pb-5 bg-gradient-to-b from-black/75 via-black/40 to-transparent opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none"
>
<div class="min-w-0 flex-1 text-white image-meta">
<p class="font-medium text-xs truncate">{{ shortName(attachment.name) }}</p>
<p class="text-[10px] opacity-90">{{ formatBytes(attachment.size) }}</p>
</div>
<DownloadLink
:url="attachment.url"
class="text-white hover:text-white hover:bg-white/15 shrink-0 pointer-events-auto -mr-0.5"
/>
</div>
</template>
<template v-else>
<div class="flex-1 flex items-center justify-center">
<component :is="fileIcon" class="w-10 h-10" :class="iconColor" />
</div>
<div class="w-full text-center">
<p class="text-xs font-medium text-foreground truncate" :title="attachment.name">
{{ shortName(attachment.name) }}
</p>
<p class="text-xs text-muted-foreground">{{ formatBytes(attachment.size) }}</p>
</div>
</template>
<DownloadLink
v-if="!isImage"
:url="attachment.url"
class="text-white hover:text-white hover:bg-white/15 shrink-0 pointer-events-auto -mr-0.5"
class="absolute top-1.5 right-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
/>
</div>
</template>
<template v-else>
<div class="flex-1 flex items-center justify-center">
<component :is="fileIcon" class="w-10 h-10" :class="iconColor" />
</div>
<div class="w-full text-center">
<p
class="text-xs font-medium text-foreground truncate"
:title="attachment.name"
>
{{ shortName(attachment.name) }}
</p>
<p class="text-xs text-muted-foreground">{{ formatBytes(attachment.size) }}</p>
</div>
</template>
<DownloadLink
v-if="!isImage"
:url="attachment.url"
class="absolute top-1.5 right-1.5 opacity-0 group-hover:opacity-100 transition-opacity"
/>
</div>
</PopoverTrigger>
<PopoverContent v-if="isAudio" class="w-80 p-3" @click.stop>
<p class="text-xs font-medium truncate mb-2" :title="attachment.name">
{{ attachment.name }}
</p>
<audio :src="attachment.url" controls autoplay preload="auto" class="w-full h-8" />
</PopoverContent>
</Popover>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { formatBytes, getThumbFilepath } from '@shared-ui/utils/file'
import DownloadLink from '@/components/DownloadLink.vue'
import { Popover, PopoverContent, PopoverTrigger } from '@shared-ui/components/ui/popover'
import {
FileText,
FileSpreadsheet,
File,
FileImage,
FileArchive,
FileCode
FileCode,
FileAudio
} from 'lucide-vue-next'
const props = defineProps({
@@ -71,11 +80,13 @@ const props = defineProps({
})
const emit = defineEmits(['preview'])
const showAudio = ref(false)
const shortName = (name) => (name || '').substring(0, 40)
const isImage = computed(() =>
(props.attachment.content_type || '').startsWith('image/')
)
const isImage = computed(() => (props.attachment.content_type || '').startsWith('image/'))
const isAudio = computed(() => (props.attachment.content_type || '').startsWith('audio/'))
const ext = computed(() => {
const parts = (props.attachment.name || '').split('.')
@@ -83,6 +94,7 @@ const ext = computed(() => {
})
const fileIcon = computed(() => {
if (isAudio.value) return FileAudio
const e = ext.value
if (e === 'pdf') return FileText
if (['xls', 'xlsx', 'csv'].includes(e)) return FileSpreadsheet
@@ -94,6 +106,7 @@ const fileIcon = computed(() => {
})
const iconColor = computed(() => {
if (isAudio.value) return 'text-purple-500'
const e = ext.value
if (e === 'pdf') return 'text-red-500'
if (['xls', 'xlsx', 'csv'].includes(e)) return 'text-green-600'
@@ -105,6 +118,8 @@ const iconColor = computed(() => {
const onClick = () => {
if (isImage.value) {
emit('preview', props.attachment)
} else if (isAudio.value) {
showAudio.value = true
} else {
window.open(props.attachment.url, '_blank', 'noopener,noreferrer')
}
@@ -1,23 +1,11 @@
<template>
<div class="flex flex-row flex-wrap gap-2 break-all">
<div
<BubbleAttachmentItem
v-for="attachment in attachments"
:key="attachment.uuid"
class="flex items-center cursor-pointer"
>
<div>
<div
v-if="isAudio(attachment)"
class="flex items-center gap-2 rounded border bg-muted/40 px-3 py-2"
>
<audio controls preload="auto" class="h-8 max-w-[260px]">
<source :src="attachment.url" />
</audio>
<DownloadLink :url="attachment.url" class="shrink-0" />
</div>
<BubbleAttachmentItem v-else :attachment="attachment" @preview="openLightbox" />
</div>
</div>
:attachment="attachment"
@preview="openLightbox"
/>
</div>
<ImageLightbox
@@ -31,14 +19,12 @@
import { ref, computed } from 'vue'
import BubbleAttachmentItem from '@/features/conversation/message/attachment/BubbleAttachmentItem.vue'
import ImageLightbox from '@/components/ImageLightbox.vue'
import DownloadLink from '@/components/DownloadLink.vue'
const props = defineProps({
attachments: { type: Array, required: true }
})
const isImage = (attachment) => (attachment.content_type || '').startsWith('image/')
const isAudio = (attachment) => (attachment.content_type || '').startsWith('audio/')
const imageAttachments = computed(() =>
(props.attachments || []).filter(isImage)