3042ddca38
- /wa-inbox, /wa-sessions e /wa-secretaria rodam sem a sidebar do scoreodonto (isStandaloneView); cada tela tem seu "Voltar" (Secretária ganhou botão na ThinNav). - SessionsView: banner "Você já possui acesso ao WhatsApp" quando há sessão ativa. - avatares: usa a URL do CDN (contactAvatarUrl/instance.avatar) direto; Avatar exibe sem depender de version; self-heal no onError re-busca a foto atual. - deps: framer-motion, immer. Dev override com HMR (docker-compose.dev.yml). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
261 lines
12 KiB
TypeScript
261 lines
12 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import {
|
|
X,
|
|
Download,
|
|
Reply,
|
|
Star,
|
|
ZoomIn,
|
|
ZoomOut,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Video,
|
|
} from 'lucide-react';
|
|
import { Message } from '../types/inboxTypes';
|
|
import { getMediaUrl } from '../utils/inboxUtils';
|
|
import { format } from 'date-fns';
|
|
import { parseSafeDate } from '../utils/inboxUtils';
|
|
|
|
interface MediaViewerProps {
|
|
msgs: Message[]
|
|
currentMsgId: string
|
|
onClose: () => void
|
|
onReply?: (msg: Message) => void
|
|
onStar?: (msg: Message) => void
|
|
}
|
|
|
|
const MediaViewer: React.FC<MediaViewerProps> = ({ msgs, currentMsgId, onClose, onReply, onStar }) => {
|
|
const [currentIdx, setCurrentIdx] = useState(() => {
|
|
const idx = msgs.findIndex(m => m.message_id === currentMsgId)
|
|
return idx >= 0 ? idx : 0
|
|
})
|
|
const [zoom, setZoom] = useState(1)
|
|
const filmstripRef = useRef<HTMLDivElement>(null)
|
|
|
|
const currentMsg = msgs[currentIdx]
|
|
const currentUrl = getMediaUrl(currentMsg?.media_url)
|
|
|
|
const goNext = useCallback(() => {
|
|
setCurrentIdx(i => Math.min(i + 1, msgs.length - 1))
|
|
setZoom(1)
|
|
}, [msgs.length])
|
|
|
|
const goPrev = useCallback(() => {
|
|
setCurrentIdx(i => Math.max(i - 1, 0))
|
|
setZoom(1)
|
|
}, [])
|
|
|
|
// Keyboard: ← → Esc
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose()
|
|
if (e.key === 'ArrowRight') goNext()
|
|
if (e.key === 'ArrowLeft') goPrev()
|
|
}
|
|
window.addEventListener('keydown', handler)
|
|
return () => window.removeEventListener('keydown', handler)
|
|
}, [onClose, goNext, goPrev])
|
|
|
|
// Scroll filmstrip to keep current thumbnail visible
|
|
useEffect(() => {
|
|
const el = filmstripRef.current
|
|
if (!el) return
|
|
const thumb = el.children[currentIdx] as HTMLElement
|
|
if (thumb) thumb.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' })
|
|
}, [currentIdx])
|
|
|
|
if (!currentMsg) return null
|
|
|
|
const isVideo = currentMsg.type === 'video'
|
|
const isImage = currentMsg.type === 'image'
|
|
const caption = currentMsg.text?.trim() || null
|
|
const timeStr = currentMsg.timestamp ? format(parseSafeDate(currentMsg.timestamp), 'dd/MM/yyyy HH:mm') : ''
|
|
const senderLabel = currentMsg.direction === 'out' ? 'Você' : 'Contato'
|
|
|
|
const viewer = (
|
|
<div className="fixed inset-0 z-[9999] flex flex-col bg-[#111b21]">
|
|
{/* ── Top bar ─────────────────────────────────────────── */}
|
|
<div className="flex items-center justify-between px-2 py-1.5 bg-[#202c33] shrink-0">
|
|
{/* Left: back + sender info */}
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
|
|
title="Fechar"
|
|
>
|
|
<ChevronLeft className="w-5 h-5" />
|
|
</button>
|
|
<div className="leading-tight">
|
|
<div className="text-white text-[13.5px] font-medium">{senderLabel}</div>
|
|
<div className="text-[#aebac1] text-[11px]">{timeStr}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right: actions */}
|
|
<div className="flex items-center">
|
|
{isImage && (
|
|
<>
|
|
<button
|
|
onClick={() => setZoom(z => Math.min(z + 0.3, 4))}
|
|
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
|
|
title="Aumentar zoom"
|
|
>
|
|
<ZoomIn className="w-5 h-5" />
|
|
</button>
|
|
<button
|
|
onClick={() => setZoom(z => Math.max(z - 0.3, 0.4))}
|
|
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
|
|
title="Diminuir zoom"
|
|
>
|
|
<ZoomOut className="w-5 h-5" />
|
|
</button>
|
|
</>
|
|
)}
|
|
<button
|
|
onClick={() => { onReply?.(currentMsg); onClose(); }}
|
|
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
|
|
title="Responder"
|
|
>
|
|
<Reply className="w-5 h-5" />
|
|
</button>
|
|
<button
|
|
onClick={() => onStar?.(currentMsg)}
|
|
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
|
|
title="Favoritar"
|
|
>
|
|
<Star className="w-5 h-5" />
|
|
</button>
|
|
{currentUrl && (
|
|
<a
|
|
href={currentUrl}
|
|
download
|
|
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
|
|
title="Baixar"
|
|
>
|
|
<Download className="w-5 h-5" />
|
|
</a>
|
|
)}
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
|
|
title="Fechar"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Main content ─────────────────────────────────────── */}
|
|
<div
|
|
className="flex-1 flex items-center justify-center relative overflow-hidden"
|
|
onClick={onClose}
|
|
>
|
|
{/* Left arrow */}
|
|
{currentIdx > 0 && (
|
|
<button
|
|
onClick={e => { e.stopPropagation(); goPrev() }}
|
|
className="absolute left-4 z-10 w-11 h-11 bg-black/50 hover:bg-black/80 text-white rounded-full flex items-center justify-center transition-colors shadow-lg"
|
|
>
|
|
<ChevronLeft className="w-6 h-6" />
|
|
</button>
|
|
)}
|
|
|
|
{/* Media */}
|
|
<div className="max-w-full max-h-full flex items-center justify-center" onClick={e => e.stopPropagation()}>
|
|
{!currentUrl ? (
|
|
<div className="text-[#aebac1] text-sm">Mídia não disponível</div>
|
|
) : isVideo ? (
|
|
<video
|
|
src={currentUrl}
|
|
controls
|
|
autoPlay
|
|
className="max-w-full rounded"
|
|
style={{ maxHeight: 'calc(100vh - 160px)' }}
|
|
/>
|
|
) : (
|
|
<img
|
|
src={currentUrl}
|
|
alt={caption || 'Imagem'}
|
|
draggable={false}
|
|
className="object-contain select-none rounded"
|
|
style={{
|
|
maxWidth: '100%',
|
|
maxHeight: 'calc(100vh - 160px)',
|
|
transform: `scale(${zoom})`,
|
|
transition: 'transform 0.18s ease',
|
|
cursor: zoom > 1 ? 'zoom-out' : 'default',
|
|
}}
|
|
onClick={e => { e.stopPropagation(); if (zoom > 1) setZoom(1) }}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right arrow */}
|
|
{currentIdx < msgs.length - 1 && (
|
|
<button
|
|
onClick={e => { e.stopPropagation(); goNext() }}
|
|
className="absolute right-4 z-10 w-11 h-11 bg-black/50 hover:bg-black/80 text-white rounded-full flex items-center justify-center transition-colors shadow-lg"
|
|
>
|
|
<ChevronRight className="w-6 h-6" />
|
|
</button>
|
|
)}
|
|
|
|
{/* Caption */}
|
|
{caption && (
|
|
<div className="absolute bottom-2 inset-x-0 flex justify-center pointer-events-none">
|
|
<span className="bg-black/60 text-white text-[13px] px-4 py-1.5 rounded-lg max-w-[70%] text-center leading-snug">
|
|
{caption}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Bottom filmstrip ─────────────────────────────────── */}
|
|
{msgs.length > 1 && (
|
|
<div className="shrink-0 bg-[#202c33] py-2 px-3">
|
|
<div
|
|
ref={filmstripRef}
|
|
className="flex gap-1.5 overflow-x-auto items-center"
|
|
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
|
>
|
|
{msgs.map((m, i) => {
|
|
const url = getMediaUrl(m.media_url)
|
|
const isVid = m.type === 'video'
|
|
const isActive = i === currentIdx
|
|
return (
|
|
<button
|
|
key={m.message_id}
|
|
onClick={() => { setCurrentIdx(i); setZoom(1) }}
|
|
className={`relative shrink-0 w-[58px] h-[58px] rounded overflow-hidden border-2 transition-all ${
|
|
isActive
|
|
? 'border-white opacity-100'
|
|
: 'border-transparent opacity-50 hover:opacity-80'
|
|
}`}
|
|
>
|
|
{url ? (
|
|
isVid ? (
|
|
<div className="w-full h-full bg-black flex items-center justify-center">
|
|
<Video className="w-5 h-5 text-white fill-white" />
|
|
</div>
|
|
) : (
|
|
<img src={url} alt="" className="w-full h-full object-cover" />
|
|
)
|
|
) : (
|
|
<div className="w-full h-full bg-[#2a3942]" />
|
|
)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
|
|
return typeof document !== 'undefined' ? createPortal(viewer, document.body) : null
|
|
}
|
|
|
|
export default MediaViewer
|