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:
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