Feature: Agregar botón para crear webhook de debug automáticamente
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m4s
All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m4s
- Agregar botón "Crear Webhook de Debug" en WebhookReceiverSection - Detectar si ya existe un webhook apuntando al receptor de debug - Permitir eliminar el webhook de debug - Incluir todos los eventos disponibles al crear el webhook - También incluye mejoras previas de manejo de media y mensajes
This commit is contained in:
172
app/components/messages/MediaPreview.vue
Normal file
172
app/components/messages/MediaPreview.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<!-- Files grid -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="(file, index) in files"
|
||||
:key="index"
|
||||
class="relative group"
|
||||
>
|
||||
<!-- Image preview -->
|
||||
<div
|
||||
v-if="isImage(file)"
|
||||
class="relative"
|
||||
>
|
||||
<img
|
||||
:src="getPreviewUrl(file)"
|
||||
class="h-20 w-20 object-cover rounded-lg"
|
||||
/>
|
||||
<button
|
||||
class="absolute -top-2 -right-2 w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
@click="$emit('remove', index)"
|
||||
>
|
||||
<UIcon name="i-lucide-x" class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Video preview -->
|
||||
<div
|
||||
v-else-if="isVideo(file)"
|
||||
class="relative"
|
||||
>
|
||||
<div class="h-20 w-20 bg-gray-800 rounded-lg flex items-center justify-center">
|
||||
<video
|
||||
:src="getPreviewUrl(file)"
|
||||
class="h-full w-full object-cover rounded-lg"
|
||||
/>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<UIcon name="i-lucide-play-circle" class="w-8 h-8 text-white/80" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="absolute -top-2 -right-2 w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
@click="$emit('remove', index)"
|
||||
>
|
||||
<UIcon name="i-lucide-x" class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Audio preview -->
|
||||
<div
|
||||
v-else-if="isAudio(file)"
|
||||
class="relative flex items-center gap-2 px-3 py-2 bg-gray-800 rounded-lg"
|
||||
>
|
||||
<UIcon name="i-lucide-music" class="w-5 h-5 text-[var(--wa-green-light)]" />
|
||||
<span class="text-sm text-white truncate max-w-[100px]">{{ file.name }}</span>
|
||||
<button
|
||||
class="w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center"
|
||||
@click="$emit('remove', index)"
|
||||
>
|
||||
<UIcon name="i-lucide-x" class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Document preview -->
|
||||
<div
|
||||
v-else
|
||||
class="relative flex items-center gap-2 px-3 py-2 bg-gray-800 rounded-lg"
|
||||
>
|
||||
<UIcon :name="getDocIcon(file)" class="w-5 h-5 text-[var(--wa-blue)]" />
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="text-sm text-white truncate max-w-[100px]">{{ file.name }}</span>
|
||||
<span class="text-xs text-gray-400">{{ formatSize(file.size) }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center flex-shrink-0"
|
||||
@click="$emit('remove', index)"
|
||||
>
|
||||
<UIcon name="i-lucide-x" class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Caption input -->
|
||||
<div v-if="showCaption && files.length > 0">
|
||||
<UInput
|
||||
:model-value="caption"
|
||||
placeholder="Agregar descripción..."
|
||||
size="sm"
|
||||
class="bg-[var(--wa-bg-light)]"
|
||||
@update:model-value="$emit('update:caption', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
files: File[]
|
||||
caption?: string
|
||||
showCaption?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
caption: '',
|
||||
showCaption: true
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
remove: [index: number]
|
||||
'update:caption': [value: string]
|
||||
}>()
|
||||
|
||||
// Preview URL cache
|
||||
const previewUrls = new Map<File, string>()
|
||||
|
||||
const getPreviewUrl = (file: File): string => {
|
||||
if (!previewUrls.has(file)) {
|
||||
previewUrls.set(file, URL.createObjectURL(file))
|
||||
}
|
||||
return previewUrls.get(file)!
|
||||
}
|
||||
|
||||
// Cleanup URLs on unmount
|
||||
onUnmounted(() => {
|
||||
previewUrls.forEach(url => URL.revokeObjectURL(url))
|
||||
previewUrls.clear()
|
||||
})
|
||||
|
||||
const isImage = (file: File): boolean => {
|
||||
return file.type.startsWith('image/')
|
||||
}
|
||||
|
||||
const isVideo = (file: File): boolean => {
|
||||
return file.type.startsWith('video/')
|
||||
}
|
||||
|
||||
const isAudio = (file: File): boolean => {
|
||||
return file.type.startsWith('audio/')
|
||||
}
|
||||
|
||||
const getDocIcon = (file: File): string => {
|
||||
const type = file.type
|
||||
const name = file.name.toLowerCase()
|
||||
|
||||
if (type.includes('pdf') || name.endsWith('.pdf')) {
|
||||
return 'i-lucide-file-text'
|
||||
}
|
||||
if (type.includes('spreadsheet') || name.match(/\.(xlsx?|csv)$/)) {
|
||||
return 'i-lucide-file-spreadsheet'
|
||||
}
|
||||
if (type.includes('word') || name.match(/\.(docx?)$/)) {
|
||||
return 'i-lucide-file-text'
|
||||
}
|
||||
if (type.includes('presentation') || name.match(/\.(pptx?)$/)) {
|
||||
return 'i-lucide-file-presentation'
|
||||
}
|
||||
if (type.includes('zip') || name.match(/\.(zip|rar|7z)$/)) {
|
||||
return 'i-lucide-file-archive'
|
||||
}
|
||||
|
||||
return 'i-lucide-file'
|
||||
}
|
||||
|
||||
const formatSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
|
||||
}
|
||||
</script>
|
||||
@@ -1,33 +1,136 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col"
|
||||
class="flex flex-col group/message"
|
||||
:class="message.fromMe ? 'items-end' : 'items-start'"
|
||||
>
|
||||
<div
|
||||
class="max-w-[70%] rounded-lg px-3 py-2"
|
||||
:class="message.fromMe ? 'bubble-out' : 'bubble-in'"
|
||||
<!-- Sender name for groups -->
|
||||
<span
|
||||
v-if="!message.fromMe && isGroup && senderName"
|
||||
class="text-xs font-medium mb-1 ml-1"
|
||||
:style="{ color: senderColor }"
|
||||
>
|
||||
<!-- Message content -->
|
||||
<p class="text-[var(--wa-text)] whitespace-pre-wrap break-words">{{ message.content }}</p>
|
||||
{{ senderName }}
|
||||
</span>
|
||||
|
||||
<!-- Image -->
|
||||
<img
|
||||
v-if="message.mediaUrl && message.type === 'image'"
|
||||
:src="message.mediaUrl"
|
||||
class="rounded-lg max-w-full mt-2"
|
||||
/>
|
||||
<div
|
||||
class="relative max-w-[70%] rounded-lg overflow-hidden"
|
||||
:class="bubbleClass"
|
||||
>
|
||||
<!-- Quoted message -->
|
||||
<div v-if="message.quoted" class="px-2 pt-2">
|
||||
<MessageQuoted
|
||||
:quoted="message.quoted"
|
||||
@click="$emit('scrollToMessage', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Caption for media -->
|
||||
<p
|
||||
v-if="message.caption"
|
||||
class="text-[var(--wa-text)] mt-2"
|
||||
<!-- Message content based on type -->
|
||||
<div :class="contentPadding">
|
||||
<!-- Text message -->
|
||||
<p
|
||||
v-if="message.type === 'text' && message.content"
|
||||
class="whitespace-pre-wrap break-words"
|
||||
:class="textClass"
|
||||
>
|
||||
{{ message.content }}
|
||||
</p>
|
||||
|
||||
<!-- Image -->
|
||||
<MessageImage
|
||||
v-else-if="message.type === 'image' && message.media"
|
||||
:media="message.media"
|
||||
:instance-id="instanceId"
|
||||
:message-id="message.messageId"
|
||||
/>
|
||||
|
||||
<!-- Video -->
|
||||
<MessageVideo
|
||||
v-else-if="message.type === 'video' && message.media"
|
||||
:media="message.media"
|
||||
:instance-id="instanceId"
|
||||
:message-id="message.messageId"
|
||||
/>
|
||||
|
||||
<!-- Audio -->
|
||||
<MessageAudio
|
||||
v-else-if="message.type === 'audio' && message.media"
|
||||
:media="message.media"
|
||||
:instance-id="instanceId"
|
||||
:message-id="message.messageId"
|
||||
:from-me="message.fromMe"
|
||||
/>
|
||||
|
||||
<!-- Document -->
|
||||
<MessageDocument
|
||||
v-else-if="message.type === 'document' && message.media"
|
||||
:media="message.media"
|
||||
:instance-id="instanceId"
|
||||
:message-id="message.messageId"
|
||||
:from-me="message.fromMe"
|
||||
/>
|
||||
|
||||
<!-- Sticker -->
|
||||
<MessageSticker
|
||||
v-else-if="message.type === 'sticker' && message.media"
|
||||
:media="message.media"
|
||||
:instance-id="instanceId"
|
||||
:message-id="message.messageId"
|
||||
/>
|
||||
|
||||
<!-- Contact -->
|
||||
<MessageContact
|
||||
v-else-if="message.type === 'contact' && message.contact"
|
||||
:contact="message.contact"
|
||||
:from-me="message.fromMe"
|
||||
/>
|
||||
|
||||
<!-- Location -->
|
||||
<MessageLocation
|
||||
v-else-if="message.type === 'location' && message.location"
|
||||
:location="message.location"
|
||||
:from-me="message.fromMe"
|
||||
/>
|
||||
|
||||
<!-- Unknown/unsupported type -->
|
||||
<div
|
||||
v-else-if="message.type === 'unknown' || !message.content"
|
||||
class="flex items-center gap-2 text-sm"
|
||||
:class="message.fromMe ? 'text-white/70' : 'text-[var(--wa-text-muted)]'"
|
||||
>
|
||||
<UIcon name="i-lucide-help-circle" class="w-4 h-4" />
|
||||
<span>Mensaje no soportado</span>
|
||||
</div>
|
||||
|
||||
<!-- Caption for media -->
|
||||
<p
|
||||
v-if="message.caption"
|
||||
class="mt-2 whitespace-pre-wrap break-words"
|
||||
:class="textClass"
|
||||
>
|
||||
{{ message.caption }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Reactions -->
|
||||
<div
|
||||
v-if="message.reactions && message.reactions.length > 0"
|
||||
class="flex flex-wrap gap-1 px-2 pb-1"
|
||||
>
|
||||
{{ message.caption }}
|
||||
</p>
|
||||
<span
|
||||
v-for="(reaction, i) in groupedReactions"
|
||||
:key="i"
|
||||
class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-xs"
|
||||
:class="message.fromMe ? 'bg-white/20' : 'bg-[var(--wa-bg-light)]'"
|
||||
:title="reaction.reactors.join(', ')"
|
||||
>
|
||||
<span>{{ reaction.emoji }}</span>
|
||||
<span v-if="reaction.count > 1" class="text-[10px] opacity-70">{{ reaction.count }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex items-center justify-end gap-1 mt-1">
|
||||
<span class="text-xs text-[var(--wa-text-muted)]">
|
||||
<!-- Footer with time and status -->
|
||||
<div class="flex items-center justify-end gap-1 px-2 pb-1.5" :class="{ 'pt-1': !message.content && !message.caption }">
|
||||
<span class="text-[11px]" :class="message.fromMe ? 'text-white/60' : 'text-[var(--wa-text-muted)]'">
|
||||
{{ formatTime(message.timestamp) }}
|
||||
</span>
|
||||
<UIcon
|
||||
@@ -36,13 +139,36 @@
|
||||
class="w-4 h-4"
|
||||
:class="statusColor"
|
||||
/>
|
||||
<!-- Debug button -->
|
||||
</div>
|
||||
|
||||
<!-- Action buttons on hover -->
|
||||
<div
|
||||
class="absolute top-1 opacity-0 group-hover/message:opacity-100 transition-opacity flex gap-0.5"
|
||||
:class="message.fromMe ? 'left-1' : 'right-1'"
|
||||
>
|
||||
<button
|
||||
@click="showDebug = !showDebug"
|
||||
class="ml-1 text-xs text-[var(--wa-text-muted)] hover:text-[var(--wa-blue)] opacity-50 hover:opacity-100"
|
||||
title="Debug info"
|
||||
class="p-1 rounded-full hover:bg-black/10"
|
||||
:class="message.fromMe ? 'text-white/60 hover:text-white' : 'text-[var(--wa-text-muted)] hover:text-[var(--wa-text)]'"
|
||||
title="Responder"
|
||||
@click="$emit('reply', message)"
|
||||
>
|
||||
<UIcon name="i-lucide-bug" class="w-3 h-3" />
|
||||
<UIcon name="i-lucide-reply" class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1 rounded-full hover:bg-black/10"
|
||||
:class="message.fromMe ? 'text-white/60 hover:text-white' : 'text-[var(--wa-text-muted)] hover:text-[var(--wa-text)]'"
|
||||
title="Reaccionar"
|
||||
@click="$emit('react', message)"
|
||||
>
|
||||
<UIcon name="i-lucide-smile-plus" class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1 rounded-full hover:bg-black/10"
|
||||
:class="message.fromMe ? 'text-white/60 hover:text-white' : 'text-[var(--wa-text-muted)] hover:text-[var(--wa-text)]'"
|
||||
title="Debug"
|
||||
@click="showDebug = !showDebug"
|
||||
>
|
||||
<UIcon name="i-lucide-bug" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,9 +181,9 @@
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-gray-400">Message:</span>
|
||||
<button
|
||||
@click="copyToClipboard(JSON.stringify(message, null, 2))"
|
||||
class="px-2 py-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-300"
|
||||
title="Copiar al portapapeles"
|
||||
@click="copyToClipboard(JSON.stringify(message, null, 2))"
|
||||
>
|
||||
<UIcon name="i-lucide-copy" class="w-3 h-3" />
|
||||
</button>
|
||||
@@ -68,39 +194,86 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Message {
|
||||
id: string
|
||||
content: string
|
||||
type: 'text' | 'image' | 'video' | 'document' | 'audio'
|
||||
mediaUrl?: string
|
||||
caption?: string
|
||||
fromMe: boolean
|
||||
timestamp: Date
|
||||
status: 'pending' | 'sent' | 'delivered' | 'read' | 'failed'
|
||||
}
|
||||
import type { Message, ReactionInfo } from '~/types/message'
|
||||
import { stringToColor } from '~/types/message'
|
||||
|
||||
// Sub-components
|
||||
import MessageQuoted from './content/MessageQuoted.vue'
|
||||
import MessageImage from './content/MessageImage.vue'
|
||||
import MessageVideo from './content/MessageVideo.vue'
|
||||
import MessageAudio from './content/MessageAudio.vue'
|
||||
import MessageDocument from './content/MessageDocument.vue'
|
||||
import MessageSticker from './content/MessageSticker.vue'
|
||||
import MessageContact from './content/MessageContact.vue'
|
||||
import MessageLocation from './content/MessageLocation.vue'
|
||||
|
||||
interface Props {
|
||||
message: Message
|
||||
instanceId: string
|
||||
isGroup?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
isGroup: false
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
reply: [message: Message]
|
||||
react: [message: Message]
|
||||
scrollToMessage: [id: string]
|
||||
}>()
|
||||
|
||||
const showDebug = ref(false)
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
}
|
||||
}
|
||||
// Computed properties
|
||||
const senderName = computed(() => {
|
||||
if (props.message.fromMe) return null
|
||||
return props.message.pushName || props.message.participant?.split('@')[0] || null
|
||||
})
|
||||
|
||||
const formatTime = (date: Date) => {
|
||||
return new Date(date).toLocaleTimeString('es-AR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
const senderColor = computed(() => {
|
||||
return stringToColor(props.message.participant || props.message.fromJid)
|
||||
})
|
||||
|
||||
const bubbleClass = computed(() => {
|
||||
const base = props.message.fromMe ? 'bubble-out' : 'bubble-in'
|
||||
|
||||
// Stickers don't have background
|
||||
if (props.message.type === 'sticker') {
|
||||
return 'bg-transparent'
|
||||
}
|
||||
|
||||
return base
|
||||
})
|
||||
|
||||
const contentPadding = computed(() => {
|
||||
// Media messages have less padding
|
||||
if (['image', 'video', 'sticker'].includes(props.message.type)) {
|
||||
return 'p-1'
|
||||
}
|
||||
return 'px-3 py-2'
|
||||
})
|
||||
|
||||
const textClass = computed(() => {
|
||||
return props.message.fromMe ? 'text-white' : 'text-[var(--wa-text)]'
|
||||
})
|
||||
|
||||
// Group reactions by emoji
|
||||
const groupedReactions = computed(() => {
|
||||
if (!props.message.reactions) return []
|
||||
|
||||
const groups: Record<string, { emoji: string; count: number; reactors: string[] }> = {}
|
||||
|
||||
for (const reaction of props.message.reactions) {
|
||||
if (!groups[reaction.emoji]) {
|
||||
groups[reaction.emoji] = { emoji: reaction.emoji, count: 0, reactors: [] }
|
||||
}
|
||||
groups[reaction.emoji].count++
|
||||
groups[reaction.emoji].reactors.push(reaction.reactorName || reaction.reactorJid)
|
||||
}
|
||||
|
||||
return Object.values(groups)
|
||||
})
|
||||
|
||||
const statusIcon = computed(() => {
|
||||
const icons: Record<string, string> = {
|
||||
@@ -116,6 +289,34 @@ const statusIcon = computed(() => {
|
||||
const statusColor = computed(() => {
|
||||
if (props.message.status === 'read') return 'text-[var(--wa-blue)]'
|
||||
if (props.message.status === 'failed') return 'text-red-500'
|
||||
return 'text-[var(--wa-text-muted)]'
|
||||
return props.message.fromMe ? 'text-white/60' : 'text-[var(--wa-text-muted)]'
|
||||
})
|
||||
|
||||
// Methods
|
||||
const formatTime = (date: Date) => {
|
||||
return new Date(date).toLocaleTimeString('es-AR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.bubble-in {
|
||||
background-color: var(--wa-surface);
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
|
||||
.bubble-out {
|
||||
background-color: var(--wa-green-dark);
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,45 +1,301 @@
|
||||
<template>
|
||||
<div class="flex items-end gap-2">
|
||||
<!-- Attachment button -->
|
||||
<UButton
|
||||
variant="ghost"
|
||||
icon="i-lucide-paperclip"
|
||||
class="text-[var(--wa-text-muted)]"
|
||||
<div class="space-y-2">
|
||||
<!-- Reply preview -->
|
||||
<div
|
||||
v-if="replyingTo"
|
||||
class="flex items-center gap-2 p-2 rounded-lg bg-[var(--wa-bg-light)]"
|
||||
>
|
||||
<div class="w-1 h-8 rounded-full bg-[var(--wa-green-light)]" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs font-medium text-[var(--wa-green-light)]">
|
||||
{{ replyingTo.fromMe ? 'Tú' : (replyingTo.pushName || 'Mensaje') }}
|
||||
</p>
|
||||
<p class="text-sm text-[var(--wa-text-muted)] truncate">
|
||||
{{ replyingTo.content || getTypePlaceholder(replyingTo.type) }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-1 rounded-full hover:bg-[var(--wa-border)]"
|
||||
@click="$emit('cancelReply')"
|
||||
>
|
||||
<UIcon name="i-lucide-x" class="w-4 h-4 text-[var(--wa-text-muted)]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Media preview -->
|
||||
<MediaPreview
|
||||
v-if="selectedFiles.length > 0"
|
||||
:files="selectedFiles"
|
||||
:caption="caption"
|
||||
@remove="removeFile"
|
||||
@update:caption="caption = $event"
|
||||
/>
|
||||
|
||||
<!-- Text input -->
|
||||
<div class="flex-1">
|
||||
<UTextarea
|
||||
v-model="message"
|
||||
placeholder="Escribe un mensaje..."
|
||||
:rows="1"
|
||||
autoresize
|
||||
:maxrows="5"
|
||||
class="bg-[var(--wa-bg-light)]"
|
||||
@keydown.enter.exact.prevent="handleSend"
|
||||
<!-- Recording indicator -->
|
||||
<div
|
||||
v-if="isRecording"
|
||||
class="flex items-center gap-3 p-3 rounded-lg bg-red-900/30"
|
||||
>
|
||||
<div class="w-3 h-3 rounded-full bg-red-500 animate-pulse" />
|
||||
<span class="text-red-400 font-medium">{{ formatDuration(recordingDuration) }}</span>
|
||||
<div class="flex-1" />
|
||||
<button
|
||||
class="p-2 rounded-full bg-red-500/20 text-red-400 hover:bg-red-500/30"
|
||||
@click="cancelRecording"
|
||||
title="Cancelar"
|
||||
>
|
||||
<UIcon name="i-lucide-trash-2" class="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
class="p-2 rounded-full bg-green-500 text-white hover:bg-green-600"
|
||||
@click="sendVoiceMessage"
|
||||
title="Enviar"
|
||||
>
|
||||
<UIcon name="i-lucide-send" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Main input area -->
|
||||
<div
|
||||
v-else
|
||||
class="flex items-end gap-2"
|
||||
:class="{ 'ring-2 ring-[var(--wa-green-light)] rounded-lg': isDragging }"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave="isDragging = false"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<!-- Attachment button -->
|
||||
<UDropdown :items="attachmentMenuItems" :popper="{ placement: 'top-start' }">
|
||||
<UButton
|
||||
variant="ghost"
|
||||
icon="i-lucide-paperclip"
|
||||
class="text-[var(--wa-text-muted)]"
|
||||
/>
|
||||
</UDropdown>
|
||||
|
||||
<!-- Hidden file inputs -->
|
||||
<input
|
||||
ref="imageInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
hidden
|
||||
@change="handleFileSelect($event, 'image')"
|
||||
/>
|
||||
<input
|
||||
ref="videoInput"
|
||||
type="file"
|
||||
accept="video/*"
|
||||
multiple
|
||||
hidden
|
||||
@change="handleFileSelect($event, 'video')"
|
||||
/>
|
||||
<input
|
||||
ref="audioInput"
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
multiple
|
||||
hidden
|
||||
@change="handleFileSelect($event, 'audio')"
|
||||
/>
|
||||
<input
|
||||
ref="documentInput"
|
||||
type="file"
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.zip,.rar"
|
||||
multiple
|
||||
hidden
|
||||
@change="handleFileSelect($event, 'document')"
|
||||
/>
|
||||
|
||||
<!-- Text input -->
|
||||
<div class="flex-1">
|
||||
<UTextarea
|
||||
v-model="message"
|
||||
placeholder="Escribe un mensaje..."
|
||||
:rows="1"
|
||||
autoresize
|
||||
:maxrows="5"
|
||||
class="bg-[var(--wa-bg-light)]"
|
||||
@keydown.enter.exact.prevent="handleSend"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Send / Mic button -->
|
||||
<UButton
|
||||
v-if="hasContent"
|
||||
icon="i-lucide-send"
|
||||
@click="handleSend"
|
||||
/>
|
||||
<UButton
|
||||
v-else
|
||||
icon="i-lucide-mic"
|
||||
:disabled="!audioRecorderSupported"
|
||||
@click="startRecording"
|
||||
:title="audioRecorderSupported ? 'Grabar nota de voz' : 'Grabación no soportada'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Send button -->
|
||||
<UButton
|
||||
:icon="message.trim() ? 'i-lucide-send' : 'i-lucide-mic'"
|
||||
:disabled="!message.trim()"
|
||||
@click="handleSend"
|
||||
/>
|
||||
<!-- Drag hint -->
|
||||
<div
|
||||
v-if="isDragging"
|
||||
class="absolute inset-0 flex items-center justify-center bg-black/50 rounded-lg pointer-events-none z-10"
|
||||
>
|
||||
<div class="text-center">
|
||||
<UIcon name="i-lucide-upload" class="w-12 h-12 text-white mb-2" />
|
||||
<p class="text-white font-medium">Suelta los archivos aquí</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Message, MessageType } from '~/types/message'
|
||||
import { getMessageTypePlaceholder } from '~/types/message'
|
||||
import { useAudioRecorder } from '~/composables/useAudioRecorder'
|
||||
|
||||
interface Props {
|
||||
replyingTo?: Message | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
replyingTo: null
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
send: [content: string]
|
||||
send: [content: string, files: File[], caption: string, quotedId?: string]
|
||||
sendVoice: [audioFile: File]
|
||||
cancelReply: []
|
||||
}>()
|
||||
|
||||
// Refs for file inputs
|
||||
const imageInput = ref<HTMLInputElement | null>(null)
|
||||
const videoInput = ref<HTMLInputElement | null>(null)
|
||||
const audioInput = ref<HTMLInputElement | null>(null)
|
||||
const documentInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
// State
|
||||
const message = ref('')
|
||||
const selectedFiles = ref<File[]>([])
|
||||
const caption = ref('')
|
||||
const isDragging = ref(false)
|
||||
|
||||
// Audio recorder
|
||||
const {
|
||||
isRecording,
|
||||
duration: recordingDuration,
|
||||
isSupported: audioRecorderSupported,
|
||||
startRecording: startAudioRecording,
|
||||
stopRecording,
|
||||
cancelRecording: cancelAudioRecording,
|
||||
getAudioFile,
|
||||
clearAudio
|
||||
} = useAudioRecorder()
|
||||
|
||||
// Computed
|
||||
const hasContent = computed(() => {
|
||||
return message.value.trim().length > 0 || selectedFiles.value.length > 0
|
||||
})
|
||||
|
||||
// Attachment menu
|
||||
const attachmentMenuItems = [
|
||||
[{
|
||||
label: 'Imagen',
|
||||
icon: 'i-lucide-image',
|
||||
click: () => imageInput.value?.click()
|
||||
}],
|
||||
[{
|
||||
label: 'Video',
|
||||
icon: 'i-lucide-video',
|
||||
click: () => videoInput.value?.click()
|
||||
}],
|
||||
[{
|
||||
label: 'Audio',
|
||||
icon: 'i-lucide-music',
|
||||
click: () => audioInput.value?.click()
|
||||
}],
|
||||
[{
|
||||
label: 'Documento',
|
||||
icon: 'i-lucide-file',
|
||||
click: () => documentInput.value?.click()
|
||||
}]
|
||||
]
|
||||
|
||||
// Methods
|
||||
const handleFileSelect = (event: Event, type: string) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
if (input.files) {
|
||||
addFiles(Array.from(input.files))
|
||||
input.value = '' // Reset input
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
isDragging.value = false
|
||||
if (event.dataTransfer?.files) {
|
||||
addFiles(Array.from(event.dataTransfer.files))
|
||||
}
|
||||
}
|
||||
|
||||
const addFiles = (files: File[]) => {
|
||||
// Limit to 10 files
|
||||
const remaining = 10 - selectedFiles.value.length
|
||||
const toAdd = files.slice(0, remaining)
|
||||
selectedFiles.value.push(...toAdd)
|
||||
}
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
selectedFiles.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const handleSend = () => {
|
||||
if (!message.value.trim()) return
|
||||
if (!hasContent.value) return
|
||||
|
||||
emit('send', message.value)
|
||||
emit(
|
||||
'send',
|
||||
message.value.trim(),
|
||||
[...selectedFiles.value],
|
||||
caption.value,
|
||||
props.replyingTo?.messageId
|
||||
)
|
||||
|
||||
// Clear state
|
||||
message.value = ''
|
||||
selectedFiles.value = []
|
||||
caption.value = ''
|
||||
}
|
||||
|
||||
const startRecording = async () => {
|
||||
const success = await startAudioRecording()
|
||||
if (!success) {
|
||||
// Show error toast
|
||||
console.error('Failed to start recording')
|
||||
}
|
||||
}
|
||||
|
||||
const cancelRecording = () => {
|
||||
cancelAudioRecording()
|
||||
}
|
||||
|
||||
const sendVoiceMessage = () => {
|
||||
stopRecording()
|
||||
|
||||
// Wait a bit for the blob to be ready
|
||||
setTimeout(() => {
|
||||
const audioFile = getAudioFile()
|
||||
if (audioFile) {
|
||||
emit('sendVoice', audioFile)
|
||||
clearAudio()
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const getTypePlaceholder = (type: MessageType): string => {
|
||||
return getMessageTypePlaceholder(type)
|
||||
}
|
||||
</script>
|
||||
|
||||
178
app/components/messages/content/MessageAudio.vue
Normal file
178
app/components/messages/content/MessageAudio.vue
Normal file
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-3 min-w-[200px] max-w-[280px]">
|
||||
<!-- Play/Pause button -->
|
||||
<button
|
||||
class="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 transition-colors"
|
||||
:class="fromMe ? 'bg-white/20 hover:bg-white/30' : 'bg-[var(--wa-green-light)] hover:bg-[var(--wa-green-dark)]'"
|
||||
:disabled="loading || error"
|
||||
@click="togglePlay"
|
||||
>
|
||||
<UIcon
|
||||
v-if="loading"
|
||||
name="i-lucide-loader-2"
|
||||
class="w-5 h-5 animate-spin"
|
||||
:class="fromMe ? 'text-white' : 'text-white'"
|
||||
/>
|
||||
<UIcon
|
||||
v-else-if="error"
|
||||
name="i-lucide-alert-circle"
|
||||
class="w-5 h-5"
|
||||
:class="fromMe ? 'text-white' : 'text-white'"
|
||||
/>
|
||||
<UIcon
|
||||
v-else
|
||||
:name="isPlaying ? 'i-lucide-pause' : 'i-lucide-play'"
|
||||
class="w-5 h-5"
|
||||
:class="[fromMe ? 'text-white' : 'text-white', !isPlaying && 'ml-0.5']"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Waveform / Progress bar -->
|
||||
<div class="relative h-6 flex items-center">
|
||||
<!-- Waveform bars -->
|
||||
<div
|
||||
class="flex items-center gap-[2px] w-full h-full"
|
||||
@click="seekTo"
|
||||
>
|
||||
<div
|
||||
v-for="(bar, i) in waveformBars"
|
||||
:key="i"
|
||||
class="flex-1 rounded-full transition-colors cursor-pointer"
|
||||
:class="i < playedBars ? (fromMe ? 'bg-white' : 'bg-[var(--wa-green-dark)]') : (fromMe ? 'bg-white/40' : 'bg-[var(--wa-text-muted)]/40')"
|
||||
:style="{ height: `${bar}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Duration -->
|
||||
<div class="flex justify-between mt-1">
|
||||
<span class="text-xs" :class="fromMe ? 'text-white/70' : 'text-[var(--wa-text-muted)]'">
|
||||
{{ currentTimeFormatted }}
|
||||
</span>
|
||||
<span class="text-xs" :class="fromMe ? 'text-white/70' : 'text-[var(--wa-text-muted)]'">
|
||||
{{ durationFormatted }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PTT indicator (mic icon for voice messages) -->
|
||||
<div
|
||||
v-if="media.isPtt"
|
||||
class="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0"
|
||||
:class="fromMe ? 'bg-white/20' : 'bg-[var(--wa-bg-light)]'"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-mic"
|
||||
class="w-4 h-4"
|
||||
:class="fromMe ? 'text-white' : 'text-[var(--wa-text-muted)]'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hidden audio element -->
|
||||
<audio
|
||||
ref="audioRef"
|
||||
:src="audioUrl"
|
||||
preload="metadata"
|
||||
@loadedmetadata="onLoadedMetadata"
|
||||
@timeupdate="onTimeUpdate"
|
||||
@play="isPlaying = true"
|
||||
@pause="isPlaying = false"
|
||||
@ended="onEnded"
|
||||
@error="onError"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MediaInfo } from '~/types/message'
|
||||
import { formatDuration } from '~/types/message'
|
||||
|
||||
interface Props {
|
||||
media: MediaInfo
|
||||
instanceId: string
|
||||
messageId: string
|
||||
fromMe?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
fromMe: false
|
||||
})
|
||||
|
||||
const audioRef = ref<HTMLAudioElement | null>(null)
|
||||
const isPlaying = ref(false)
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
const currentTime = ref(0)
|
||||
const duration = ref(props.media.duration || 0)
|
||||
|
||||
const audioUrl = computed(() => {
|
||||
if (props.media.url) return props.media.url
|
||||
return `/api/media/${props.instanceId}/${props.messageId}`
|
||||
})
|
||||
|
||||
// Generar barras de waveform (fake si no hay datos reales)
|
||||
const waveformBars = computed(() => {
|
||||
if (props.media.waveform && props.media.waveform.length > 0) {
|
||||
// Normalizar waveform real a porcentajes
|
||||
const max = Math.max(...props.media.waveform)
|
||||
return props.media.waveform.slice(0, 40).map(v => Math.max(15, (v / max) * 100))
|
||||
}
|
||||
|
||||
// Generar waveform fake basado en messageId para que sea consistente
|
||||
const seed = props.messageId.split('').reduce((a, b) => a + b.charCodeAt(0), 0)
|
||||
return Array.from({ length: 40 }, (_, i) => {
|
||||
const val = Math.sin(seed + i * 0.5) * 0.5 + 0.5
|
||||
return 15 + val * 85
|
||||
})
|
||||
})
|
||||
|
||||
const playedBars = computed(() => {
|
||||
if (duration.value === 0) return 0
|
||||
return Math.floor((currentTime.value / duration.value) * waveformBars.value.length)
|
||||
})
|
||||
|
||||
const currentTimeFormatted = computed(() => formatDuration(currentTime.value))
|
||||
const durationFormatted = computed(() => formatDuration(duration.value))
|
||||
|
||||
const togglePlay = () => {
|
||||
if (!audioRef.value || error.value) return
|
||||
|
||||
if (isPlaying.value) {
|
||||
audioRef.value.pause()
|
||||
} else {
|
||||
audioRef.value.play()
|
||||
}
|
||||
}
|
||||
|
||||
const seekTo = (e: MouseEvent) => {
|
||||
if (!audioRef.value || duration.value === 0) return
|
||||
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const percent = (e.clientX - rect.left) / rect.width
|
||||
audioRef.value.currentTime = percent * duration.value
|
||||
}
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
loading.value = false
|
||||
if (audioRef.value && !props.media.duration) {
|
||||
duration.value = audioRef.value.duration
|
||||
}
|
||||
}
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
if (audioRef.value) {
|
||||
currentTime.value = audioRef.value.currentTime
|
||||
}
|
||||
}
|
||||
|
||||
const onEnded = () => {
|
||||
isPlaying.value = false
|
||||
currentTime.value = 0
|
||||
}
|
||||
|
||||
const onError = () => {
|
||||
loading.value = false
|
||||
error.value = true
|
||||
}
|
||||
</script>
|
||||
152
app/components/messages/content/MessageContact.vue
Normal file
152
app/components/messages/content/MessageContact.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 rounded-lg min-w-[200px] max-w-[280px]"
|
||||
:class="fromMe ? 'bg-white/10' : 'bg-[var(--wa-bg-light)]'"
|
||||
>
|
||||
<!-- Avatar placeholder -->
|
||||
<div
|
||||
class="w-12 h-12 rounded-full flex items-center justify-center flex-shrink-0"
|
||||
:class="fromMe ? 'bg-white/20' : 'bg-[var(--wa-border)]'"
|
||||
>
|
||||
<UIcon
|
||||
name="i-lucide-user"
|
||||
class="w-6 h-6"
|
||||
:class="fromMe ? 'text-white' : 'text-[var(--wa-text-muted)]'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Contact info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="font-medium truncate"
|
||||
:class="fromMe ? 'text-white' : 'text-[var(--wa-text)]'"
|
||||
>
|
||||
{{ contact.displayName }}
|
||||
</p>
|
||||
<p
|
||||
v-if="primaryPhone"
|
||||
class="text-sm mt-0.5 truncate"
|
||||
:class="fromMe ? 'text-white/70' : 'text-[var(--wa-text-muted)]'"
|
||||
>
|
||||
{{ primaryPhone }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<div
|
||||
class="flex gap-2 mt-2"
|
||||
:class="fromMe ? 'justify-end' : 'justify-start'"
|
||||
>
|
||||
<UButton
|
||||
size="xs"
|
||||
:variant="fromMe ? 'soft' : 'outline'"
|
||||
:class="fromMe ? 'text-white border-white/30' : ''"
|
||||
@click="viewContact"
|
||||
>
|
||||
<UIcon name="i-lucide-eye" class="w-3 h-3 mr-1" />
|
||||
Ver
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="primaryPhone"
|
||||
size="xs"
|
||||
:variant="fromMe ? 'soft' : 'outline'"
|
||||
:class="fromMe ? 'text-white border-white/30' : ''"
|
||||
@click="sendMessage"
|
||||
>
|
||||
<UIcon name="i-lucide-message-circle" class="w-3 h-3 mr-1" />
|
||||
Mensaje
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Contact details modal -->
|
||||
<UModal v-model="showDetails">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 rounded-full bg-[var(--wa-green-light)] flex items-center justify-center">
|
||||
<UIcon name="i-lucide-user" class="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-lg">{{ contact.displayName }}</h3>
|
||||
<p class="text-sm text-[var(--wa-text-muted)]">Contacto compartido</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div v-for="(phone, i) in contact.phones" :key="i" class="flex items-center gap-3">
|
||||
<UIcon name="i-lucide-phone" class="w-5 h-5 text-[var(--wa-text-muted)]" />
|
||||
<a
|
||||
:href="`tel:${phone}`"
|
||||
class="text-[var(--wa-blue)] hover:underline"
|
||||
>
|
||||
{{ phone }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-if="!contact.phones?.length" class="text-[var(--wa-text-muted)] text-sm">
|
||||
No hay números de teléfono disponibles
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton variant="ghost" @click="showDetails = false">
|
||||
Cerrar
|
||||
</UButton>
|
||||
<UButton v-if="primaryPhone" @click="copyPhone">
|
||||
<UIcon name="i-lucide-copy" class="w-4 h-4 mr-1" />
|
||||
Copiar número
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ContactInfo } from '~/types/message'
|
||||
|
||||
interface Props {
|
||||
contact: ContactInfo
|
||||
fromMe?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
fromMe: false
|
||||
})
|
||||
|
||||
const showDetails = ref(false)
|
||||
const toast = useToast()
|
||||
|
||||
const primaryPhone = computed(() => {
|
||||
if (props.contact.phones && props.contact.phones.length > 0) {
|
||||
return props.contact.phones[0]
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const viewContact = () => {
|
||||
showDetails.value = true
|
||||
}
|
||||
|
||||
const sendMessage = () => {
|
||||
if (primaryPhone.value) {
|
||||
// Formatear número para WhatsApp
|
||||
const number = primaryPhone.value.replace(/\D/g, '')
|
||||
window.open(`https://wa.me/${number}`, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const copyPhone = async () => {
|
||||
if (primaryPhone.value) {
|
||||
await navigator.clipboard.writeText(primaryPhone.value)
|
||||
toast.add({
|
||||
title: 'Número copiado',
|
||||
icon: 'i-lucide-check',
|
||||
color: 'green'
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
213
app/components/messages/content/MessageDocument.vue
Normal file
213
app/components/messages/content/MessageDocument.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-colors min-w-[200px] max-w-[280px]"
|
||||
:class="fromMe ? 'bg-white/10 hover:bg-white/20' : 'bg-[var(--wa-bg-light)] hover:bg-[var(--wa-border)]'"
|
||||
@click="downloadFile"
|
||||
>
|
||||
<!-- File icon -->
|
||||
<div
|
||||
class="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
:class="iconBgClass"
|
||||
>
|
||||
<UIcon :name="fileIcon" class="w-5 h-5 text-white" />
|
||||
</div>
|
||||
|
||||
<!-- File info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p
|
||||
class="text-sm font-medium truncate"
|
||||
:class="fromMe ? 'text-white' : 'text-[var(--wa-text)]'"
|
||||
>
|
||||
{{ fileName }}
|
||||
</p>
|
||||
<p
|
||||
class="text-xs mt-0.5"
|
||||
:class="fromMe ? 'text-white/70' : 'text-[var(--wa-text-muted)]'"
|
||||
>
|
||||
{{ fileInfo }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Download indicator -->
|
||||
<div class="flex-shrink-0">
|
||||
<UIcon
|
||||
v-if="loading"
|
||||
name="i-lucide-loader-2"
|
||||
class="w-5 h-5 animate-spin"
|
||||
:class="fromMe ? 'text-white/70' : 'text-[var(--wa-text-muted)]'"
|
||||
/>
|
||||
<UIcon
|
||||
v-else
|
||||
name="i-lucide-download"
|
||||
class="w-5 h-5"
|
||||
:class="fromMe ? 'text-white/70' : 'text-[var(--wa-text-muted)]'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MediaInfo } from '~/types/message'
|
||||
import { formatFileSize } from '~/types/message'
|
||||
|
||||
interface Props {
|
||||
media: MediaInfo
|
||||
instanceId: string
|
||||
messageId: string
|
||||
fromMe?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
fromMe: false
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
const fileName = computed(() => {
|
||||
return props.media.filename || 'Documento'
|
||||
})
|
||||
|
||||
const fileInfo = computed(() => {
|
||||
const parts: string[] = []
|
||||
|
||||
// Extensión
|
||||
const ext = getFileExtension(props.media.filename || '', props.media.mimetype)
|
||||
if (ext) parts.push(ext.toUpperCase())
|
||||
|
||||
// Tamaño
|
||||
if (props.media.filesize) {
|
||||
parts.push(formatFileSize(props.media.filesize))
|
||||
}
|
||||
|
||||
return parts.join(' • ') || 'Documento'
|
||||
})
|
||||
|
||||
const fileIcon = computed(() => {
|
||||
const mimetype = props.media.mimetype || ''
|
||||
const filename = props.media.filename || ''
|
||||
|
||||
// PDF
|
||||
if (mimetype.includes('pdf') || filename.endsWith('.pdf')) {
|
||||
return 'i-lucide-file-text'
|
||||
}
|
||||
|
||||
// Hojas de cálculo
|
||||
if (mimetype.includes('spreadsheet') || mimetype.includes('excel') ||
|
||||
filename.match(/\.(xlsx?|csv|ods)$/i)) {
|
||||
return 'i-lucide-file-spreadsheet'
|
||||
}
|
||||
|
||||
// Documentos de texto
|
||||
if (mimetype.includes('word') || mimetype.includes('document') ||
|
||||
filename.match(/\.(docx?|odt|rtf)$/i)) {
|
||||
return 'i-lucide-file-text'
|
||||
}
|
||||
|
||||
// Presentaciones
|
||||
if (mimetype.includes('presentation') || mimetype.includes('powerpoint') ||
|
||||
filename.match(/\.(pptx?|odp)$/i)) {
|
||||
return 'i-lucide-file-presentation'
|
||||
}
|
||||
|
||||
// Código
|
||||
if (mimetype.includes('javascript') || mimetype.includes('json') ||
|
||||
mimetype.includes('html') || mimetype.includes('css') ||
|
||||
filename.match(/\.(js|ts|json|html|css|py|java|c|cpp|go|rs)$/i)) {
|
||||
return 'i-lucide-file-code'
|
||||
}
|
||||
|
||||
// Comprimidos
|
||||
if (mimetype.includes('zip') || mimetype.includes('rar') ||
|
||||
mimetype.includes('tar') || mimetype.includes('gzip') ||
|
||||
filename.match(/\.(zip|rar|7z|tar|gz)$/i)) {
|
||||
return 'i-lucide-file-archive'
|
||||
}
|
||||
|
||||
// APK
|
||||
if (filename.endsWith('.apk')) {
|
||||
return 'i-lucide-smartphone'
|
||||
}
|
||||
|
||||
return 'i-lucide-file'
|
||||
})
|
||||
|
||||
const iconBgClass = computed(() => {
|
||||
const mimetype = props.media.mimetype || ''
|
||||
const filename = props.media.filename || ''
|
||||
|
||||
// PDF - rojo
|
||||
if (mimetype.includes('pdf') || filename.endsWith('.pdf')) {
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
// Excel - verde
|
||||
if (mimetype.includes('spreadsheet') || mimetype.includes('excel') ||
|
||||
filename.match(/\.(xlsx?|csv)$/i)) {
|
||||
return 'bg-green-600'
|
||||
}
|
||||
|
||||
// Word - azul
|
||||
if (mimetype.includes('word') || filename.match(/\.(docx?)$/i)) {
|
||||
return 'bg-blue-600'
|
||||
}
|
||||
|
||||
// PowerPoint - naranja
|
||||
if (mimetype.includes('presentation') || filename.match(/\.(pptx?)$/i)) {
|
||||
return 'bg-orange-500'
|
||||
}
|
||||
|
||||
// Comprimidos - amarillo
|
||||
if (mimetype.includes('zip') || mimetype.includes('rar') ||
|
||||
filename.match(/\.(zip|rar|7z|tar|gz)$/i)) {
|
||||
return 'bg-yellow-600'
|
||||
}
|
||||
|
||||
// Código - morado
|
||||
if (mimetype.includes('javascript') || mimetype.includes('json') ||
|
||||
filename.match(/\.(js|ts|json|py|java)$/i)) {
|
||||
return 'bg-purple-600'
|
||||
}
|
||||
|
||||
return 'bg-gray-500'
|
||||
})
|
||||
|
||||
function getFileExtension(filename: string, mimetype?: string): string {
|
||||
// Intentar extraer de filename
|
||||
const match = filename.match(/\.([a-zA-Z0-9]+)$/)
|
||||
if (match) return match[1]
|
||||
|
||||
// Intentar extraer de mimetype
|
||||
if (mimetype) {
|
||||
const parts = mimetype.split('/')
|
||||
if (parts.length > 1) {
|
||||
const subtype = parts[1]
|
||||
// Limpiar formatos como "vnd.openxmlformats-officedocument..."
|
||||
if (subtype.includes('spreadsheet')) return 'xlsx'
|
||||
if (subtype.includes('document')) return 'docx'
|
||||
if (subtype.includes('presentation')) return 'pptx'
|
||||
return subtype
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const downloadFile = async () => {
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const url = props.media.url || `/api/media/${props.instanceId}/${props.messageId}`
|
||||
|
||||
// Crear link de descarga
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = props.media.filename || 'documento'
|
||||
a.target = '_blank'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
174
app/components/messages/content/MessageImage.vue
Normal file
174
app/components/messages/content/MessageImage.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div class="relative group">
|
||||
<!-- Image -->
|
||||
<img
|
||||
v-if="!error"
|
||||
:src="imageUrl"
|
||||
:alt="'Imagen'"
|
||||
class="rounded-lg max-w-full cursor-pointer"
|
||||
:class="[
|
||||
{ 'max-h-80': !expanded },
|
||||
{ 'animate-pulse bg-[var(--wa-bg-light)]': loading }
|
||||
]"
|
||||
@load="onLoad"
|
||||
@error="onError"
|
||||
@click="toggleExpand"
|
||||
/>
|
||||
|
||||
<!-- Error state -->
|
||||
<div
|
||||
v-if="error"
|
||||
class="flex flex-col items-center justify-center p-8 bg-[var(--wa-bg-light)] rounded-lg min-w-[200px]"
|
||||
>
|
||||
<UIcon name="i-lucide-image-off" class="w-12 h-12 text-[var(--wa-text-muted)] mb-2" />
|
||||
<p class="text-sm text-[var(--wa-text-muted)]">No se pudo cargar la imagen</p>
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
class="mt-2"
|
||||
@click="retryLoad"
|
||||
>
|
||||
Reintentar
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Loading placeholder with thumbnail -->
|
||||
<div
|
||||
v-if="loading && !error"
|
||||
class="relative"
|
||||
>
|
||||
<!-- Blur thumbnail as placeholder -->
|
||||
<img
|
||||
v-if="media.thumbnail"
|
||||
:src="`data:image/jpeg;base64,${media.thumbnail}`"
|
||||
class="rounded-lg max-w-full max-h-80 blur-sm"
|
||||
:style="{ width: `${media.width || 200}px`, height: `${media.height || 200}px`, objectFit: 'cover' }"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg bg-[var(--wa-bg-light)]"
|
||||
:style="{ width: `${media.width || 200}px`, height: `${media.height || 200}px` }"
|
||||
/>
|
||||
|
||||
<!-- Loading spinner overlay -->
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<UIcon name="i-lucide-loader-2" class="w-8 h-8 text-white animate-spin drop-shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View once indicator -->
|
||||
<div
|
||||
v-if="media.isViewOnce && !error"
|
||||
class="absolute top-2 left-2 px-2 py-1 rounded bg-black/50 text-white text-xs flex items-center gap-1"
|
||||
>
|
||||
<UIcon name="i-lucide-eye" class="w-3 h-3" />
|
||||
<span>Vista única</span>
|
||||
</div>
|
||||
|
||||
<!-- Expand/download buttons on hover -->
|
||||
<div
|
||||
v-if="!error && !loading"
|
||||
class="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<button
|
||||
class="p-1.5 rounded-full bg-black/50 text-white hover:bg-black/70"
|
||||
@click.stop="downloadImage"
|
||||
title="Descargar"
|
||||
>
|
||||
<UIcon name="i-lucide-download" class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="p-1.5 rounded-full bg-black/50 text-white hover:bg-black/70"
|
||||
@click.stop="openFullscreen"
|
||||
title="Ver en pantalla completa"
|
||||
>
|
||||
<UIcon name="i-lucide-maximize" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fullscreen modal -->
|
||||
<UModal v-model="showFullscreen" :ui="{ width: 'max-w-[95vw]' }">
|
||||
<div class="relative bg-black flex items-center justify-center min-h-[50vh]">
|
||||
<img
|
||||
:src="imageUrl"
|
||||
:alt="'Imagen'"
|
||||
class="max-w-full max-h-[90vh] object-contain"
|
||||
/>
|
||||
<button
|
||||
class="absolute top-4 right-4 p-2 rounded-full bg-black/50 text-white hover:bg-black/70"
|
||||
@click="showFullscreen = false"
|
||||
>
|
||||
<UIcon name="i-lucide-x" class="w-6 h-6" />
|
||||
</button>
|
||||
<button
|
||||
class="absolute bottom-4 right-4 p-2 rounded-full bg-black/50 text-white hover:bg-black/70"
|
||||
@click="downloadImage"
|
||||
>
|
||||
<UIcon name="i-lucide-download" class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
</UModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MediaInfo } from '~/types/message'
|
||||
|
||||
interface Props {
|
||||
media: MediaInfo
|
||||
instanceId: string
|
||||
messageId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
const expanded = ref(false)
|
||||
const showFullscreen = ref(false)
|
||||
|
||||
const imageUrl = computed(() => {
|
||||
if (props.media.url) return props.media.url
|
||||
return `/api/media/${props.instanceId}/${props.messageId}`
|
||||
})
|
||||
|
||||
const onLoad = () => {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const onError = () => {
|
||||
loading.value = false
|
||||
error.value = true
|
||||
}
|
||||
|
||||
const retryLoad = () => {
|
||||
error.value = false
|
||||
loading.value = true
|
||||
}
|
||||
|
||||
const toggleExpand = () => {
|
||||
expanded.value = !expanded.value
|
||||
}
|
||||
|
||||
const openFullscreen = () => {
|
||||
showFullscreen.value = true
|
||||
}
|
||||
|
||||
const downloadImage = async () => {
|
||||
try {
|
||||
const response = await fetch(imageUrl.value)
|
||||
const blob = await response.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `imagen-${props.messageId}.jpg`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (e) {
|
||||
console.error('Error downloading image:', e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
118
app/components/messages/content/MessageLocation.vue
Normal file
118
app/components/messages/content/MessageLocation.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div
|
||||
class="rounded-lg overflow-hidden cursor-pointer max-w-[280px]"
|
||||
@click="openInMaps"
|
||||
>
|
||||
<!-- Map preview image -->
|
||||
<div class="relative">
|
||||
<img
|
||||
:src="mapImageUrl"
|
||||
:alt="location.name || 'Ubicación'"
|
||||
class="w-full h-[150px] object-cover"
|
||||
@error="mapError = true"
|
||||
/>
|
||||
|
||||
<!-- Fallback si la imagen falla -->
|
||||
<div
|
||||
v-if="mapError"
|
||||
class="w-full h-[150px] flex items-center justify-center bg-[var(--wa-bg-light)]"
|
||||
>
|
||||
<UIcon name="i-lucide-map" class="w-12 h-12 text-[var(--wa-text-muted)]" />
|
||||
</div>
|
||||
|
||||
<!-- Pin overlay -->
|
||||
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div class="w-8 h-8 -mt-4">
|
||||
<UIcon name="i-lucide-map-pin" class="w-8 h-8 text-red-500 drop-shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location info -->
|
||||
<div
|
||||
class="p-3"
|
||||
:class="fromMe ? 'bg-white/10' : 'bg-[var(--wa-bg-light)]'"
|
||||
>
|
||||
<p
|
||||
v-if="location.name"
|
||||
class="font-medium truncate"
|
||||
:class="fromMe ? 'text-white' : 'text-[var(--wa-text)]'"
|
||||
>
|
||||
{{ location.name }}
|
||||
</p>
|
||||
<p
|
||||
v-if="location.address"
|
||||
class="text-sm mt-0.5 line-clamp-2"
|
||||
:class="fromMe ? 'text-white/70' : 'text-[var(--wa-text-muted)]'"
|
||||
>
|
||||
{{ location.address }}
|
||||
</p>
|
||||
<p
|
||||
v-if="!location.name && !location.address"
|
||||
class="text-sm"
|
||||
:class="fromMe ? 'text-white/70' : 'text-[var(--wa-text-muted)]'"
|
||||
>
|
||||
{{ formatCoordinates(location.latitude, location.longitude) }}
|
||||
</p>
|
||||
|
||||
<!-- Open in maps hint -->
|
||||
<div
|
||||
class="flex items-center gap-1 mt-2 text-xs"
|
||||
:class="fromMe ? 'text-white/50' : 'text-[var(--wa-text-muted)]'"
|
||||
>
|
||||
<UIcon name="i-lucide-external-link" class="w-3 h-3" />
|
||||
<span>Abrir en Google Maps</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LocationInfo } from '~/types/message'
|
||||
|
||||
interface Props {
|
||||
location: LocationInfo
|
||||
fromMe?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
fromMe: false
|
||||
})
|
||||
|
||||
const mapError = ref(false)
|
||||
|
||||
// URL de imagen estática de mapa (usando OpenStreetMap tiles)
|
||||
const mapImageUrl = computed(() => {
|
||||
const { latitude, longitude } = props.location
|
||||
const zoom = 15
|
||||
|
||||
// Usar Google Static Maps API (requiere API key en producción)
|
||||
// Por ahora usamos un servicio gratuito alternativo
|
||||
return `https://static-maps.yandex.ru/1.x/?ll=${longitude},${latitude}&z=${zoom}&l=map&size=300,150&pt=${longitude},${latitude},pm2rdm`
|
||||
})
|
||||
|
||||
const mapsUrl = computed(() => {
|
||||
const { latitude, longitude, name } = props.location
|
||||
const query = name ? encodeURIComponent(name) : `${latitude},${longitude}`
|
||||
return `https://www.google.com/maps/search/?api=1&query=${query}`
|
||||
})
|
||||
|
||||
const openInMaps = () => {
|
||||
window.open(mapsUrl.value, '_blank')
|
||||
}
|
||||
|
||||
const formatCoordinates = (lat: number, lng: number): string => {
|
||||
const latDir = lat >= 0 ? 'N' : 'S'
|
||||
const lngDir = lng >= 0 ? 'E' : 'W'
|
||||
return `${Math.abs(lat).toFixed(4)}° ${latDir}, ${Math.abs(lng).toFixed(4)}° ${lngDir}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
78
app/components/messages/content/MessageQuoted.vue
Normal file
78
app/components/messages/content/MessageQuoted.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex rounded-lg overflow-hidden cursor-pointer hover:opacity-80 transition-opacity"
|
||||
:class="quoted.fromMe ? 'bg-[var(--wa-green-light)]/20' : 'bg-[var(--wa-bg-light)]'"
|
||||
@click="$emit('click', quoted.id)"
|
||||
>
|
||||
<!-- Barra de color lateral -->
|
||||
<div
|
||||
class="w-1 flex-shrink-0"
|
||||
:style="{ backgroundColor: barColor }"
|
||||
/>
|
||||
|
||||
<div class="flex-1 px-2 py-1.5 min-w-0">
|
||||
<!-- Nombre del autor -->
|
||||
<p
|
||||
class="text-xs font-medium truncate"
|
||||
:style="{ color: barColor }"
|
||||
>
|
||||
{{ authorName }}
|
||||
</p>
|
||||
|
||||
<!-- Contenido según tipo -->
|
||||
<div class="flex items-center gap-1.5 mt-0.5">
|
||||
<!-- Thumbnail si es media -->
|
||||
<img
|
||||
v-if="quoted.media?.thumbnail"
|
||||
:src="`data:image/jpeg;base64,${quoted.media.thumbnail}`"
|
||||
class="w-10 h-10 rounded object-cover flex-shrink-0"
|
||||
/>
|
||||
|
||||
<!-- Icono de tipo si no es texto -->
|
||||
<UIcon
|
||||
v-if="quoted.type !== 'text' && !quoted.media?.thumbnail"
|
||||
:name="typeIcon"
|
||||
class="w-4 h-4 text-[var(--wa-text-muted)] flex-shrink-0"
|
||||
/>
|
||||
|
||||
<!-- Texto o placeholder -->
|
||||
<p class="text-sm text-[var(--wa-text-muted)] truncate">
|
||||
{{ displayText }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { QuotedMessage } from '~/types/message'
|
||||
import { getMessageTypePlaceholder, getMessageTypeIcon, stringToColor } from '~/types/message'
|
||||
|
||||
interface Props {
|
||||
quoted: QuotedMessage
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
defineEmits<{
|
||||
click: [id: string]
|
||||
}>()
|
||||
|
||||
const authorName = computed(() => {
|
||||
if (props.quoted.fromMe) return 'Tú'
|
||||
return props.quoted.participantName || props.quoted.participant?.split('@')[0] || 'Desconocido'
|
||||
})
|
||||
|
||||
const barColor = computed(() => {
|
||||
if (props.quoted.fromMe) return 'var(--wa-green-dark)'
|
||||
return stringToColor(props.quoted.participant || '')
|
||||
})
|
||||
|
||||
const typeIcon = computed(() => getMessageTypeIcon(props.quoted.type))
|
||||
|
||||
const displayText = computed(() => {
|
||||
if (props.quoted.content) return props.quoted.content
|
||||
if (props.quoted.media?.filename) return props.quoted.media.filename
|
||||
return getMessageTypePlaceholder(props.quoted.type)
|
||||
})
|
||||
</script>
|
||||
56
app/components/messages/content/MessageSticker.vue
Normal file
56
app/components/messages/content/MessageSticker.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<div class="relative inline-block">
|
||||
<!-- Sticker image -->
|
||||
<img
|
||||
v-if="!error"
|
||||
:src="stickerUrl"
|
||||
:alt="'Sticker'"
|
||||
class="max-w-[180px] max-h-[180px] object-contain"
|
||||
:class="{ 'animate-pulse bg-[var(--wa-bg-light)] rounded-lg': loading }"
|
||||
@load="loading = false"
|
||||
@error="onError"
|
||||
/>
|
||||
|
||||
<!-- Error state -->
|
||||
<div
|
||||
v-if="error"
|
||||
class="w-[120px] h-[120px] flex flex-col items-center justify-center bg-[var(--wa-bg-light)] rounded-lg"
|
||||
>
|
||||
<UIcon name="i-lucide-image-off" class="w-8 h-8 text-[var(--wa-text-muted)]" />
|
||||
<span class="text-xs text-[var(--wa-text-muted)] mt-1">Sticker</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading placeholder -->
|
||||
<div
|
||||
v-if="loading && !error"
|
||||
class="w-[120px] h-[120px] flex items-center justify-center bg-[var(--wa-bg-light)] rounded-lg"
|
||||
>
|
||||
<UIcon name="i-lucide-loader-2" class="w-6 h-6 text-[var(--wa-text-muted)] animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MediaInfo } from '~/types/message'
|
||||
|
||||
interface Props {
|
||||
media: MediaInfo
|
||||
instanceId: string
|
||||
messageId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
|
||||
const stickerUrl = computed(() => {
|
||||
if (props.media.url) return props.media.url
|
||||
return `/api/media/${props.instanceId}/${props.messageId}`
|
||||
})
|
||||
|
||||
const onError = () => {
|
||||
loading.value = false
|
||||
error.value = true
|
||||
}
|
||||
</script>
|
||||
150
app/components/messages/content/MessageVideo.vue
Normal file
150
app/components/messages/content/MessageVideo.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div class="relative group">
|
||||
<!-- Video player -->
|
||||
<video
|
||||
v-if="!error"
|
||||
ref="videoRef"
|
||||
:src="videoUrl"
|
||||
:poster="posterUrl"
|
||||
class="rounded-lg max-w-full"
|
||||
:class="{ 'max-h-80': !fullscreen }"
|
||||
:controls="isPlaying"
|
||||
preload="metadata"
|
||||
@loadedmetadata="onLoadedMetadata"
|
||||
@play="isPlaying = true"
|
||||
@pause="isPlaying = false"
|
||||
@ended="isPlaying = false"
|
||||
@error="onError"
|
||||
/>
|
||||
|
||||
<!-- Placeholder con play button cuando no está reproduciendo -->
|
||||
<div
|
||||
v-if="!isPlaying && !error"
|
||||
class="absolute inset-0 flex items-center justify-center cursor-pointer bg-black/20 rounded-lg"
|
||||
@click="playVideo"
|
||||
>
|
||||
<!-- Play button -->
|
||||
<div class="w-14 h-14 rounded-full bg-black/50 flex items-center justify-center backdrop-blur-sm">
|
||||
<UIcon name="i-lucide-play" class="w-8 h-8 text-white ml-1" />
|
||||
</div>
|
||||
|
||||
<!-- Duration badge -->
|
||||
<div
|
||||
v-if="duration"
|
||||
class="absolute bottom-2 left-2 px-1.5 py-0.5 rounded bg-black/60 text-white text-xs"
|
||||
>
|
||||
{{ formattedDuration }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div
|
||||
v-if="error"
|
||||
class="flex flex-col items-center justify-center p-8 bg-[var(--wa-bg-light)] rounded-lg"
|
||||
>
|
||||
<UIcon name="i-lucide-video-off" class="w-12 h-12 text-[var(--wa-text-muted)] mb-2" />
|
||||
<p class="text-sm text-[var(--wa-text-muted)]">No se pudo cargar el video</p>
|
||||
<UButton
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
class="mt-2"
|
||||
@click="retryLoad"
|
||||
>
|
||||
Reintentar
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="absolute inset-0 flex items-center justify-center bg-[var(--wa-bg-light)] rounded-lg"
|
||||
>
|
||||
<UIcon name="i-lucide-loader-2" class="w-8 h-8 text-[var(--wa-text-muted)] animate-spin" />
|
||||
</div>
|
||||
|
||||
<!-- Fullscreen button -->
|
||||
<button
|
||||
v-if="!error && isPlaying"
|
||||
class="absolute top-2 right-2 p-1.5 rounded-full bg-black/50 text-white opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<UIcon :name="fullscreen ? 'i-lucide-minimize' : 'i-lucide-maximize'" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { MediaInfo } from '~/types/message'
|
||||
import { formatDuration } from '~/types/message'
|
||||
|
||||
interface Props {
|
||||
media: MediaInfo
|
||||
instanceId: string
|
||||
messageId: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const videoRef = ref<HTMLVideoElement | null>(null)
|
||||
const isPlaying = ref(false)
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
const fullscreen = ref(false)
|
||||
const duration = ref(props.media.duration || 0)
|
||||
|
||||
const videoUrl = computed(() => {
|
||||
if (props.media.url) return props.media.url
|
||||
return `/api/media/${props.instanceId}/${props.messageId}`
|
||||
})
|
||||
|
||||
const posterUrl = computed(() => {
|
||||
if (props.media.thumbnail) {
|
||||
return `data:image/jpeg;base64,${props.media.thumbnail}`
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
const formattedDuration = computed(() => formatDuration(duration.value))
|
||||
|
||||
const playVideo = () => {
|
||||
if (videoRef.value) {
|
||||
videoRef.value.play()
|
||||
}
|
||||
}
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
loading.value = false
|
||||
if (videoRef.value && !props.media.duration) {
|
||||
duration.value = videoRef.value.duration
|
||||
}
|
||||
}
|
||||
|
||||
const onError = () => {
|
||||
loading.value = false
|
||||
error.value = true
|
||||
}
|
||||
|
||||
const retryLoad = () => {
|
||||
error.value = false
|
||||
loading.value = true
|
||||
}
|
||||
|
||||
const toggleFullscreen = async () => {
|
||||
if (!videoRef.value) return
|
||||
|
||||
if (!fullscreen.value) {
|
||||
await videoRef.value.requestFullscreen()
|
||||
fullscreen.value = true
|
||||
} else {
|
||||
await document.exitFullscreen()
|
||||
fullscreen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Escuchar cambios de fullscreen
|
||||
onMounted(() => {
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
fullscreen.value = !!document.fullscreenElement
|
||||
})
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user