feat(inbox): preview de arquivo estilo WhatsApp + delay no preloader para avatares

This commit is contained in:
VPS 4 Deploy Agent
2026-05-11 07:56:40 +02:00
parent 8015ee5233
commit 8b3b368b56
4 changed files with 204 additions and 6 deletions
@@ -0,0 +1,176 @@
'use client';
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Send, FileText, Film, Music, Archive, File } from 'lucide-react';
interface FilePreviewModalProps {
file: File | null;
onConfirm: (file: File, caption: string) => Promise<void>;
onCancel: () => void;
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function getFileIcon(type: string) {
if (type.startsWith('video/')) return <Film className="w-12 h-12 text-blue-400" />;
if (type.startsWith('audio/')) return <Music className="w-12 h-12 text-purple-400" />;
if (type.includes('pdf') || type.includes('document') || type.includes('word'))
return <FileText className="w-12 h-12 text-red-400" />;
if (type.includes('zip') || type.includes('compressed') || type.includes('tar'))
return <Archive className="w-12 h-12 text-yellow-400" />;
return <File className="w-12 h-12 text-[#8696a0]" />;
}
function getFileLabel(type: string): string {
if (type.startsWith('image/')) return 'Imagem';
if (type.startsWith('video/')) return 'Vídeo';
if (type.startsWith('audio/')) return 'Áudio';
if (type.includes('pdf')) return 'PDF';
return 'Arquivo';
}
export default function FilePreviewModal({ file, onConfirm, onCancel }: FilePreviewModalProps) {
const [caption, setCaption] = useState('');
const [sending, setSending] = useState(false);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const captionRef = useRef<HTMLInputElement>(null);
const isImage = file?.type.startsWith('image/');
const isVideo = file?.type.startsWith('video/');
// Create / revoke object URL
useEffect(() => {
if (!file) { setObjectUrl(null); return; }
const url = URL.createObjectURL(file);
setObjectUrl(url);
setCaption('');
setSending(false);
setTimeout(() => captionRef.current?.focus(), 200);
return () => URL.revokeObjectURL(url);
}, [file]);
// ESC cancels
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onCancel(); };
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onCancel]);
const handleSend = useCallback(async () => {
if (!file || sending) return;
setSending(true);
try {
await onConfirm(file, caption.trim());
} finally {
setSending(false);
}
}, [file, caption, sending, onConfirm]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<AnimatePresence>
{file && (
<motion.div
key="file-preview-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-[9998] flex flex-col bg-[#111b21]"
>
{/* ── Top bar ── */}
<div className="flex items-center justify-between px-5 py-3 bg-[#202c33] shrink-0">
<button
onClick={onCancel}
className="p-2 rounded-full hover:bg-white/10 text-[#aebac1] transition-colors"
>
<X className="w-5 h-5" />
</button>
<span className="text-[#e9edef] text-sm font-medium">
{file ? getFileLabel(file.type) : ''}
</span>
<div className="w-9" /> {/* spacer */}
</div>
{/* ── Preview area ── */}
<div className="flex-1 flex items-center justify-center p-4 min-h-0 overflow-hidden">
{isImage && objectUrl && (
<motion.img
src={objectUrl}
alt="preview"
initial={{ scale: 0.92, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.25 }}
className="max-w-full max-h-full object-contain rounded-xl shadow-2xl"
/>
)}
{isVideo && objectUrl && (
<motion.video
src={objectUrl}
controls
initial={{ scale: 0.92, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.25 }}
className="max-w-full max-h-full rounded-xl shadow-2xl"
/>
)}
{!isImage && !isVideo && file && (
<motion.div
initial={{ scale: 0.92, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.25 }}
className="flex flex-col items-center gap-4 bg-[#202c33] rounded-2xl px-12 py-10 shadow-2xl"
>
{getFileIcon(file.type)}
<div className="text-center">
<p className="text-[#e9edef] text-sm font-medium max-w-[280px] break-all text-center">
{file.name}
</p>
<p className="text-[#8696a0] text-xs mt-1">{formatFileSize(file.size)}</p>
</div>
</motion.div>
)}
</div>
{/* ── Caption + Send bar ── */}
<div className="shrink-0 bg-[#202c33] px-4 py-3 flex items-center gap-3">
<input
ref={captionRef}
type="text"
placeholder="Adicionar legenda…"
value={caption}
onChange={(e) => setCaption(e.target.value)}
onKeyDown={handleKeyDown}
disabled={sending}
className="flex-1 bg-[#2a3942] text-[#e9edef] placeholder:text-[#8696a0] text-sm rounded-full px-4 py-2.5 outline-none border-none focus:ring-0 disabled:opacity-50"
/>
<button
onClick={handleSend}
disabled={sending}
className="w-11 h-11 bg-[#00a884] hover:bg-[#02c89f] disabled:opacity-50 rounded-full flex items-center justify-center transition-colors shadow-md active:scale-95"
>
{sending ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<Send className="w-5 h-5 text-white" />
)}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
@@ -20,6 +20,7 @@ import { groupApi, type GroupParticipant, type MessageTemplate, type TemplateTyp
import RichMessageComposer from './RichMessageComposer';
import TemplateLibrary from './TemplateLibrary';
import ScheduleMessageModal from './ScheduleMessageModal';
import FilePreviewModal from './FilePreviewModal';
interface Message {
id: number;
@@ -34,7 +35,7 @@ interface InputBarProps {
onNewMessageChange: (val: string) => void;
onSend: (e?: React.FormEvent) => void;
onPresence: () => void;
onSendMedia: (file: File) => Promise<void>;
onSendMedia: (file: File, caption?: string) => Promise<void>;
onSendAudio: (blob: Blob) => Promise<void>;
replyingTo: Message | null;
onCancelReply: () => void;
@@ -64,6 +65,7 @@ const InputBar: React.FC<InputBarProps> = ({
const fileInputRef = useRef<HTMLInputElement>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [showAttachMenu, setShowAttachMenu] = useState(false);
const [previewFile, setPreviewFile] = useState<File | null>(null);
const attachMenuRef = useRef<HTMLDivElement>(null);
const [showRichComposer, setShowRichComposer] = useState(false);
const [showTemplateLibrary, setShowTemplateLibrary] = useState(false);
@@ -229,14 +231,21 @@ const InputBar: React.FC<InputBarProps> = ({
// Do not close picker, common UX allows multiple emojis
};
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
await onSendMedia(file);
setPreviewFile(file);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handlePreviewConfirm = async (file: File, caption: string) => {
await onSendMedia(file, caption);
setPreviewFile(null);
};
const handlePreviewCancel = () => setPreviewFile(null);
const startRecording = async () => {
if (!micAvailable) {
setMicError('Áudio requer HTTPS. Acesse via https:// para gravar.');
@@ -625,6 +634,13 @@ const InputBar: React.FC<InputBarProps> = ({
</div>
</div>
{/* ── Modal de preview de arquivo ─────────────────────── */}
<FilePreviewModal
file={previewFile}
onConfirm={handlePreviewConfirm}
onCancel={handlePreviewCancel}
/>
{/* ── Modais de mensagem rica ────────────────────────────── */}
<RichMessageComposer
isOpen={showRichComposer}
@@ -75,10 +75,10 @@ export function useChatWindow({
// Será implementado via endpoint /api/instances/:id/react
}, [])
const handleSendMedia = useCallback(async (file: File) => {
const handleSendMedia = useCallback(async (file: File, caption?: string) => {
if (!selectedChat || !activeInstanceId) return
try {
await mediaApi.sendMedia(activeInstanceId, selectedChat.remote_jid, file, undefined, undefined)
await mediaApi.sendMedia(activeInstanceId, selectedChat.remote_jid, file, caption || undefined, undefined)
} catch (err) {
console.error('Erro ao enviar mídia:', err)
}
@@ -128,11 +128,17 @@ export default function WhatsAppInboxPage() {
const [isNewConvoModalOpen, setIsNewConvoModalOpen] = useState(false)
// Preloader: começa visível e some após o primeiro ciclo loading true→false
// O delay de 400ms dá tempo ao browser iniciar o carregamento dos avatares
// antes do overlay sumir, evitando o flash de skeletons pulsando.
const [pageReady, setPageReady] = useState(false)
const prevLoadingRef = useRef(false)
const readyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (prevLoadingRef.current && !loading) setPageReady(true)
if (prevLoadingRef.current && !loading) {
readyTimerRef.current = setTimeout(() => setPageReady(true), 400)
}
prevLoadingRef.current = loading
return () => { if (readyTimerRef.current) clearTimeout(readyTimerRef.current) }
}, [loading])
const optionsRef = useRef<HTMLDivElement>(null)