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

- 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:
2025-12-02 21:21:33 -06:00
parent 71593b25e9
commit 80d0042c7e
21 changed files with 3722 additions and 112 deletions

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

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

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

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

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

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

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

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