245 lines
11 KiB
TypeScript
245 lines
11 KiB
TypeScript
'use client';
|
||
|
||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
import { motion, AnimatePresence } from 'framer-motion';
|
||
import { X, Send, FileText, Film, Music, Archive, File, ZoomIn, ZoomOut } 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-16 h-16 text-blue-400" />;
|
||
if (type.startsWith('audio/')) return <Music className="w-16 h-16 text-purple-400" />;
|
||
if (type.includes('pdf') || type.includes('document') || type.includes('word'))
|
||
return <FileText className="w-16 h-16 text-red-400" />;
|
||
if (type.includes('zip') || type.includes('compressed') || type.includes('tar'))
|
||
return <Archive className="w-16 h-16 text-yellow-400" />;
|
||
return <File className="w-16 h-16 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';
|
||
}
|
||
|
||
function ModalContent({ file, onConfirm, onCancel }: FilePreviewModalProps & { file: File }) {
|
||
const [caption, setCaption] = useState('');
|
||
const [sending, setSending] = useState(false);
|
||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||
const [zoomed, setZoomed] = useState(false);
|
||
// Posição do mouse como % dentro da área da imagem (para o zoom seguir o cursor)
|
||
const [mouseOrigin, setMouseOrigin] = useState({ x: 50, y: 50 });
|
||
const captionRef = useRef<HTMLInputElement>(null);
|
||
const previewAreaRef = useRef<HTMLDivElement>(null);
|
||
|
||
const isImage = file.type.startsWith('image/');
|
||
const isVideo = file.type.startsWith('video/');
|
||
|
||
useEffect(() => {
|
||
const url = URL.createObjectURL(file);
|
||
setObjectUrl(url);
|
||
setCaption('');
|
||
setSending(false);
|
||
setZoomed(false);
|
||
setMouseOrigin({ x: 50, y: 50 });
|
||
setTimeout(() => captionRef.current?.focus(), 250);
|
||
return () => URL.revokeObjectURL(url);
|
||
}, [file]);
|
||
|
||
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 (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.preventDefault(); handleSend(); }
|
||
};
|
||
|
||
// Atualiza a origem do zoom conforme o mouse se move na área de preview
|
||
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||
if (!isImage) return;
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||
setMouseOrigin({ x, y });
|
||
}, [isImage]);
|
||
|
||
const toggleZoom = useCallback(() => setZoomed(z => !z), []);
|
||
|
||
return (
|
||
<motion.div
|
||
key="file-preview-overlay"
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
exit={{ opacity: 0 }}
|
||
transition={{ duration: 0.18 }}
|
||
className="fixed inset-0 flex flex-col bg-[#0b141a]"
|
||
style={{ zIndex: 99999 }}
|
||
>
|
||
{/* ── Top bar ── */}
|
||
<div className="flex items-center gap-3 px-4 py-3 bg-[#202c33] shrink-0 shadow-md">
|
||
{/* Info do arquivo — lado esquerdo */}
|
||
<div className="flex-1 min-w-0">
|
||
<p className="text-[#e9edef] text-sm font-medium truncate">{file.name}</p>
|
||
<p className="text-[#8696a0] text-xs">{getFileLabel(file.type)} · {formatFileSize(file.size)}</p>
|
||
</div>
|
||
|
||
{/* Botões — lado direito: zoom (se imagem) → fechar */}
|
||
<div className="flex items-center gap-1 shrink-0">
|
||
{isImage && (
|
||
<button
|
||
onClick={toggleZoom}
|
||
className="p-2 rounded-full hover:bg-white/10 text-[#aebac1] transition-colors"
|
||
title={zoomed ? 'Zoom normal' : 'Ampliar'}
|
||
>
|
||
{zoomed ? <ZoomOut className="w-5 h-5" /> : <ZoomIn className="w-5 h-5" />}
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={onCancel}
|
||
className="p-2 rounded-full hover:bg-white/10 text-[#aebac1] transition-colors"
|
||
title="Fechar"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Preview area ── */}
|
||
<div
|
||
ref={previewAreaRef}
|
||
className="flex-1 flex items-center justify-center min-h-0 overflow-hidden p-4"
|
||
onMouseMove={handleMouseMove}
|
||
onClick={isImage ? toggleZoom : undefined}
|
||
style={{ cursor: isImage ? (zoomed ? 'zoom-out' : 'zoom-in') : 'default' }}
|
||
>
|
||
{isImage && objectUrl && (
|
||
<motion.img
|
||
src={objectUrl}
|
||
alt="preview"
|
||
initial={{ scale: 0.94, opacity: 0 }}
|
||
animate={{ scale: 1, opacity: 1 }}
|
||
transition={{ duration: 0.22 }}
|
||
draggable={false}
|
||
className="select-none rounded-lg shadow-2xl"
|
||
style={zoomed ? {
|
||
// Zoom 2.5× centrado no cursor
|
||
transform: `scale(2.5)`,
|
||
transformOrigin: `${mouseOrigin.x}% ${mouseOrigin.y}%`,
|
||
transition: 'transform-origin 0s', // origin segue o mouse instantaneamente
|
||
maxWidth: '100%',
|
||
maxHeight: 'calc(100vh - 160px)',
|
||
objectFit: 'contain',
|
||
cursor: 'zoom-out',
|
||
} : {
|
||
maxWidth: '100%',
|
||
maxHeight: 'calc(100vh - 160px)',
|
||
objectFit: 'contain',
|
||
cursor: 'zoom-in',
|
||
transition: 'transform 0.2s ease',
|
||
transform: 'scale(1)',
|
||
transformOrigin: 'center center',
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{isVideo && objectUrl && (
|
||
<motion.video
|
||
src={objectUrl}
|
||
controls
|
||
initial={{ scale: 0.94, opacity: 0 }}
|
||
animate={{ scale: 1, opacity: 1 }}
|
||
transition={{ duration: 0.22 }}
|
||
className="max-w-full max-h-full rounded-lg shadow-2xl"
|
||
style={{ maxHeight: 'calc(100vh - 160px)' }}
|
||
/>
|
||
)}
|
||
|
||
{!isImage && !isVideo && (
|
||
<motion.div
|
||
initial={{ scale: 0.92, opacity: 0 }}
|
||
animate={{ scale: 1, opacity: 1 }}
|
||
transition={{ duration: 0.22 }}
|
||
className="flex flex-col items-center gap-5 bg-[#202c33] rounded-2xl px-14 py-12 shadow-2xl max-w-sm w-full"
|
||
>
|
||
<div className="w-24 h-24 rounded-2xl bg-[#2a3942] flex items-center justify-center">
|
||
{getFileIcon(file.type)}
|
||
</div>
|
||
<div className="text-center space-y-1">
|
||
<p className="text-[#e9edef] text-sm font-medium break-all">{file.name}</p>
|
||
<p className="text-[#8696a0] text-xs">{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 shadow-[0_-1px_0_rgba(255,255,255,0.06)]">
|
||
<input
|
||
ref={captionRef}
|
||
type="text"
|
||
placeholder="Adicionar legenda…"
|
||
value={caption}
|
||
onChange={(e) => setCaption(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
disabled={sending}
|
||
maxLength={1024}
|
||
className="flex-1 bg-[#2a3942] text-[#e9edef] placeholder:text-[#8696a0] text-sm rounded-full px-5 py-2.5 outline-none border-none focus:ring-0 disabled:opacity-50 transition-opacity"
|
||
/>
|
||
<button
|
||
onClick={handleSend}
|
||
disabled={sending}
|
||
className="w-12 h-12 bg-[#00a884] hover:bg-[#00c49a] disabled:opacity-60 rounded-full flex items-center justify-center transition-all shadow-lg active:scale-95 shrink-0"
|
||
>
|
||
{sending
|
||
? <div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||
: <Send className="w-5 h-5 text-white translate-x-px" />
|
||
}
|
||
</button>
|
||
</div>
|
||
</motion.div>
|
||
);
|
||
}
|
||
|
||
export default function FilePreviewModal({ file, onConfirm, onCancel }: FilePreviewModalProps) {
|
||
const [mounted, setMounted] = useState(false);
|
||
useEffect(() => { setMounted(true); }, []);
|
||
if (!mounted) return null;
|
||
|
||
return createPortal(
|
||
<AnimatePresence>
|
||
{file && (
|
||
<ModalContent
|
||
file={file}
|
||
onConfirm={onConfirm}
|
||
onCancel={onCancel}
|
||
/>
|
||
)}
|
||
</AnimatePresence>,
|
||
document.body
|
||
);
|
||
}
|