All checks were successful
build-and-deploy / build-and-deploy (push) Successful in 1m10s
455 lines
12 KiB
Vue
455 lines
12 KiB
Vue
<template>
|
|
<div class="space-y-2">
|
|
<!-- Debug Panel -->
|
|
<div
|
|
v-if="showDebug"
|
|
class="p-2 rounded bg-gray-900 border border-gray-700 text-xs font-mono max-h-60 overflow-auto"
|
|
>
|
|
<div class="flex items-center justify-between mb-2">
|
|
<span class="text-gray-400">Estado del Input:</span>
|
|
<button
|
|
@click="copyDebugInfo"
|
|
class="px-2 py-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-300"
|
|
title="Copiar al portapapeles"
|
|
>
|
|
<UIcon name="i-lucide-copy" class="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
<pre class="text-green-400 whitespace-pre-wrap">{{ debugInfo }}</pre>
|
|
</div>
|
|
|
|
<!-- Reply preview with animation -->
|
|
<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"
|
|
@update:stickerModes="stickerModes = $event"
|
|
/>
|
|
|
|
<!-- 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 -->
|
|
<UDropdownMenu :items="attachmentMenuItems" :content="{ side: 'top', align: 'start' }">
|
|
<UButton
|
|
variant="ghost"
|
|
icon="i-lucide-paperclip"
|
|
class="text-[var(--wa-text-muted)]"
|
|
/>
|
|
</UDropdownMenu>
|
|
|
|
<!-- 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="inputPlaceholder"
|
|
:rows="1"
|
|
autoresize
|
|
:maxrows="5"
|
|
class="bg-[var(--wa-bg-light)]"
|
|
@keydown.enter.exact.prevent="handleSend"
|
|
@input="emit('typing')"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Debug button -->
|
|
<UButton
|
|
variant="ghost"
|
|
icon="i-lucide-bug"
|
|
class="text-gray-400"
|
|
title="Debug input state"
|
|
@click="showDebug = !showDebug"
|
|
/>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- 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, files: File[], caption: string, quotedId?: string, stickerModes?: boolean[]]
|
|
sendVoice: [audioFile: File]
|
|
cancelReply: []
|
|
typing: []
|
|
recording: [isRecording: boolean]
|
|
}>()
|
|
|
|
// 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)
|
|
const stickerModes = ref<boolean[]>([])
|
|
const showDebug = ref(false)
|
|
|
|
// File size limits (in bytes) - should match server
|
|
const MAX_SIZES: Record<string, number> = {
|
|
image: 16 * 1024 * 1024, // 16 MB
|
|
video: 64 * 1024 * 1024, // 64 MB
|
|
audio: 16 * 1024 * 1024, // 16 MB
|
|
document: 100 * 1024 * 1024, // 100 MB
|
|
}
|
|
|
|
const toast = useToast()
|
|
|
|
// 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
|
|
})
|
|
|
|
const inputPlaceholder = computed(() => {
|
|
if (selectedFiles.value.length > 0) {
|
|
const count = selectedFiles.value.length
|
|
return `${count} archivo${count > 1 ? 's' : ''} seleccionado${count > 1 ? 's' : ''} - Presiona enviar`
|
|
}
|
|
return 'Escribe un mensaje...'
|
|
})
|
|
|
|
// Debug info computed
|
|
const debugInfo = computed(() => {
|
|
const filesInfo = selectedFiles.value.map((f, i) => ({
|
|
index: i,
|
|
name: f.name,
|
|
type: f.type,
|
|
size: formatFileSize(f.size),
|
|
sizeBytes: f.size,
|
|
asSticker: stickerModes.value[i] || false
|
|
}))
|
|
|
|
return JSON.stringify({
|
|
message: message.value,
|
|
caption: caption.value,
|
|
filesCount: selectedFiles.value.length,
|
|
files: filesInfo,
|
|
stickerModes: stickerModes.value,
|
|
hasContent: hasContent.value,
|
|
replyingTo: props.replyingTo ? {
|
|
messageId: props.replyingTo.messageId,
|
|
type: props.replyingTo.type,
|
|
fromMe: props.replyingTo.fromMe
|
|
} : null,
|
|
isRecording: isRecording.value,
|
|
isDragging: isDragging.value
|
|
}, null, 2)
|
|
})
|
|
|
|
// Copy debug info to clipboard
|
|
const copyDebugInfo = async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(debugInfo.value)
|
|
toast.add({
|
|
title: 'Copiado al portapapeles',
|
|
icon: 'i-lucide-check',
|
|
color: 'success',
|
|
duration: 2000
|
|
})
|
|
} catch (err) {
|
|
console.error('Failed to copy:', err)
|
|
toast.add({
|
|
title: 'Error al copiar',
|
|
icon: 'i-lucide-x',
|
|
color: 'error',
|
|
duration: 2000
|
|
})
|
|
}
|
|
}
|
|
|
|
// Attachment menu
|
|
const attachmentMenuItems = [
|
|
[{
|
|
label: 'Imagen',
|
|
icon: 'i-lucide-image',
|
|
onSelect: () => imageInput.value?.click()
|
|
}],
|
|
[{
|
|
label: 'Video',
|
|
icon: 'i-lucide-video',
|
|
onSelect: () => videoInput.value?.click()
|
|
}],
|
|
[{
|
|
label: 'Audio',
|
|
icon: 'i-lucide-music',
|
|
onSelect: () => audioInput.value?.click()
|
|
}],
|
|
[{
|
|
label: 'Documento',
|
|
icon: 'i-lucide-file',
|
|
onSelect: () => documentInput.value?.click()
|
|
}]
|
|
]
|
|
|
|
// Methods
|
|
const handleFileSelect = (event: Event, type: string) => {
|
|
console.log('[MessageInput] handleFileSelect called, type:', type)
|
|
const input = event.target as HTMLInputElement
|
|
console.log('[MessageInput] input.files:', input.files?.length)
|
|
if (input.files && input.files.length > 0) {
|
|
addFiles(Array.from(input.files))
|
|
input.value = '' // Reset input
|
|
}
|
|
}
|
|
|
|
const getFileMediaType = (file: File): string => {
|
|
if (file.type.startsWith('image/')) return 'image'
|
|
if (file.type.startsWith('video/')) return 'video'
|
|
if (file.type.startsWith('audio/')) return 'audio'
|
|
return 'document'
|
|
}
|
|
|
|
const formatFileSize = (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]}`
|
|
}
|
|
|
|
const addFiles = (files: File[]) => {
|
|
console.log('[MessageInput] addFiles called with', files.length, 'files')
|
|
|
|
// Limit to 10 files
|
|
const remaining = 10 - selectedFiles.value.length
|
|
const toAdd = files.slice(0, remaining)
|
|
console.log('[MessageInput] toAdd:', toAdd.length, 'files')
|
|
|
|
// Validate file sizes
|
|
const validFiles: File[] = []
|
|
for (const file of toAdd) {
|
|
const mediaType = getFileMediaType(file)
|
|
const maxSize = MAX_SIZES[mediaType]
|
|
console.log('[MessageInput] Processing file:', file.name, 'type:', mediaType, 'size:', file.size)
|
|
|
|
if (file.size > maxSize) {
|
|
toast.add({
|
|
title: 'Archivo muy grande',
|
|
description: `${file.name} (${formatFileSize(file.size)}) excede el límite de ${formatFileSize(maxSize)} para ${mediaType}`,
|
|
color: 'error',
|
|
duration: 5000
|
|
})
|
|
} else {
|
|
validFiles.push(file)
|
|
}
|
|
}
|
|
|
|
console.log('[MessageInput] validFiles:', validFiles.length)
|
|
|
|
if (validFiles.length > 0) {
|
|
// Use spread operator to trigger reactivity
|
|
selectedFiles.value = [...selectedFiles.value, ...validFiles]
|
|
console.log('[MessageInput] selectedFiles now has', selectedFiles.value.length, 'files')
|
|
}
|
|
}
|
|
|
|
const handleDrop = (event: DragEvent) => {
|
|
isDragging.value = false
|
|
if (event.dataTransfer?.files) {
|
|
addFiles(Array.from(event.dataTransfer.files))
|
|
}
|
|
}
|
|
|
|
const removeFile = (index: number) => {
|
|
// Use filter to maintain reactivity
|
|
selectedFiles.value = selectedFiles.value.filter((_, i) => i !== index)
|
|
}
|
|
|
|
const handleSend = () => {
|
|
if (!hasContent.value) return
|
|
|
|
emit(
|
|
'send',
|
|
message.value.trim(),
|
|
[...selectedFiles.value],
|
|
caption.value,
|
|
props.replyingTo?.messageId,
|
|
[...stickerModes.value]
|
|
)
|
|
|
|
// Clear state
|
|
message.value = ''
|
|
selectedFiles.value = []
|
|
caption.value = ''
|
|
stickerModes.value = []
|
|
}
|
|
|
|
const startRecording = async () => {
|
|
const success = await startAudioRecording()
|
|
if (success) {
|
|
emit('recording', true)
|
|
} else {
|
|
console.error('Failed to start recording')
|
|
}
|
|
}
|
|
|
|
const cancelRecording = () => {
|
|
cancelAudioRecording()
|
|
emit('recording', false)
|
|
}
|
|
|
|
const sendVoiceMessage = () => {
|
|
stopRecording()
|
|
emit('recording', false)
|
|
|
|
// 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>
|
|
|