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

View File

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

View File

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

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>