diff --git a/src/pages/MeetingRoom.tsx b/src/pages/MeetingRoom.tsx index 8812b37..61cd712 100644 --- a/src/pages/MeetingRoom.tsx +++ b/src/pages/MeetingRoom.tsx @@ -12,11 +12,11 @@ import { toast } from 'sonner'; import { Users, MessageSquare, Mic, MicOff, Home, Send, ArrowRight, ChevronLeft, ChevronRight, Share2, LogOut, - RotateCw, Award + RotateCw, Award, ShieldAlert } from 'lucide-react'; // Interfaces tipadas para evitar any -interface MiiPreset { +interface AvatarPreset { id: number; name: string; desc: string; @@ -54,17 +54,17 @@ const CHAIRS = [ { id: '8', name: 'Assento 8', pos: [-1.2, 0.95, 1.45], lookAt: [0, 0.95, 0] }, ]; -const AVATAR_PRESETS: MiiPreset[] = [ - { id: 1, name: 'Mii Cyber', desc: 'Cabelo curto preto e óculos vermelhos', color: '#ef4444' }, - { id: 2, name: 'Mii Classic', desc: 'Pele média com Cabelo castanho clássico', color: '#b45309' }, - { id: 3, name: 'Mii Goldie', desc: 'Cabelo loiro longo com olhos azuis expressivos', color: '#eab308' }, - { id: 4, name: 'Mii Sporty', desc: 'Boné azul virado para trás e sorriso aberto', color: '#3b82f6' }, - { id: 5, name: 'Mii Ginger', desc: 'Cabelo ruivo longo ondulado e expressivo', color: '#f97316' }, - { id: 6, name: 'Mii Sleek', desc: 'Pele parda com cabelo curto liso lateral', color: '#10b981' }, - { id: 7, name: 'Mii Beard', desc: 'Careca estilosa com barba e bigode', color: '#78350f' }, - { id: 8, name: 'Mii Sun', desc: 'Cabelo raspado e óculos escuros pretos', color: '#1e293b' }, - { id: 9, name: 'Mii Synth', desc: 'Cabelo roxo ondulado moderno', color: '#a855f7' }, - { id: 10, name: 'Mii Straw', desc: 'Chapéu de palha do campo clássico', color: '#ca8a04' }, +const AVATAR_PRESETS: AvatarPreset[] = [ + { id: 1, name: 'Cyber', desc: 'Cabelo curto preto e óculos vermelhos', color: '#ef4444' }, + { id: 2, name: 'Classic', desc: 'Pele média com Cabelo castanho clássico', color: '#b45309' }, + { id: 3, name: 'Goldie', desc: 'Cabelo loiro longo com olhos azuis expressivos', color: '#eab308' }, + { id: 4, name: 'Sporty', desc: 'Boné azul virado para trás e sorriso aberto', color: '#3b82f6' }, + { id: 5, name: 'Ginger', desc: 'Cabelo ruivo longo ondulado e expressivo', color: '#f97316' }, + { id: 6, name: 'Sleek', desc: 'Pele parda com cabelo curto liso lateral', color: '#10b981' }, + { id: 7, name: 'Beard', desc: 'Careca estilosa com barba e bigode', color: '#78350f' }, + { id: 8, name: 'Sun', desc: 'Cabelo raspado e óculos escuros pretos', color: '#1e293b' }, + { id: 9, name: 'Synth', desc: 'Cabelo roxo ondulado moderno', color: '#a855f7' }, + { id: 10, name: 'Straw', desc: 'Chapéu de palha do campo clássico', color: '#ca8a04' }, ]; const SKIN_COLORS = ['#fed7aa', '#fdba74', '#f59e0b', '#d97706', '#9a3412', '#ffedd5']; @@ -88,7 +88,7 @@ const getAvatarBgClass = (avatarId: number): string => { // --- Subcomponentes 3D --- -// Avatar estilo Mii (Nintendo Wii) +// Avatar estilo Mii (Nintendo Wii) - Versão Melhorada (Mais Feliz e Expressivo) function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: number; username: string; isTalking?: boolean } & Record) { const groupRef = useRef(null); @@ -103,7 +103,7 @@ function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: numb capColor: '#3b82f6', hasHat: avatarId === 10, hatColor: '#b45309', - hairStyle: avatarId === 7 ? 'bald' : (avatarId % 3 === 0 ? 'long' : 'short'), + hairStyle: avatarId === 7 ? 'bald' : (avatarId === 9 ? 'modern' : (avatarId % 3 === 0 ? 'long' : 'short')), beard: avatarId === 7, }; }, [avatarId]); @@ -149,18 +149,30 @@ function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: numb )} - {/* CABEÇA MII (Esfera ovalada de pele) */} + {/* CABEÇA AVATAR (Esfera ovalada de pele) */} - {/* CABELO */} + {/* CABELO MELHORADO */} {mii.hairStyle === 'short' && ( - - - - + + {/* Topo do cabelo */} + + + + + {/* Costeletas laterais */} + + + + + + + + + )} {mii.hairStyle === 'long' && ( @@ -170,13 +182,42 @@ function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: numb - {/* Cabelo lateral caindo */} - + {/* Franja frontal */} + - - + {/* Costeletas longas */} + + + + + + + + + + )} + + {mii.hairStyle === 'modern' && ( + + {/* Cabelo Superior */} + + + + + {/* Topete Moderno */} + + + + + {/* Costeletas */} + + + + + + @@ -214,22 +255,35 @@ function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: numb )} - {/* OLHOS */} - - - - - - - - + {/* OLHOS MELHORADOS (Mais expressivos e com brilho) */} + + + + + + + + + + + + + + + + + + + + + {/* SOBRANCELHAS */} - + - + @@ -240,9 +294,19 @@ function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: numb - {/* BOCA */} - - + {/* BOCHECHAS ROSADAS (Deixa o avatar super amigável e feliz) */} + + + + + + + + + + {/* BOCA FELIZ E SORRIDENTE (Sem aparência de triste) */} + + @@ -539,7 +603,7 @@ export default function MeetingRoom() { const [selectedChair, setSelectedChair] = useState('1'); const [inputRoomId, setInputRoomId] = useState(roomId || ''); - // Estados da Sala + // Estados da Sala (Conexão Supabase) const [peers, setPeers] = useState>({}); const [chatMessages, setChatMessages] = useState<{ id: string; sender: string; text: string; time: string }[]>([]); const [newMessage, setNewMessage] = useState(''); @@ -562,13 +626,13 @@ export default function MeetingRoom() { const channelRef = useRef | null>(null); const chatScrollRef = useRef(null); - // Se o roomId mudar na url, atualiza o checkbox isPresenter + // Se o roomId mudar na url, atualiza o checkbox isPresenter e o inputRoomId useEffect(() => { setIsPresenter(!roomId); setInputRoomId(roomId || ''); }, [roomId]); - // Simulação simples de detecção de áudio + // Simulação de detecção de áudio useEffect(() => { if (!inRoom || isMuted) { setIsTalking(false); @@ -586,6 +650,47 @@ export default function MeetingRoom() { } }, [chatMessages]); + // --- ESCUTA DO LOBBY EM TEMPO REAL --- + // Conecta ao canal antes do lobby finalizar para ler assentos e avatares ocupados na sala digitada + useEffect(() => { + if (!inputRoomId || inputRoomId.trim().length < 3) { + setPeers({}); + return; + } + + const roomIdUpper = inputRoomId.trim().toUpperCase(); + console.log(`🔌 [Lobby Watcher] Escutando sala: ${roomIdUpper}`); + + const tempChannel = supabase.channel(`meeting_room_${roomIdUpper}`, { + config: { + presence: { key: 'lobby_watcher_' + Math.random().toString(36).substring(3, 8) } + } + }); + + tempChannel + .on('presence', { event: 'sync' }, () => { + const state = tempChannel.presenceState(); + const formattedPeers: Record = {}; + + Object.keys(state).forEach((chairKey) => { + // Ignora outros watchers do lobby + if (chairKey.startsWith('lobby_watcher_')) return; + + const presences = state[chairKey] as unknown as PeerData[]; + if (presences && presences.length > 0) { + formattedPeers[chairKey] = presences[0]; + } + }); + + setPeers(formattedPeers); + }) + .subscribe(); + + return () => { + tempChannel.unsubscribe(); + }; + }, [inputRoomId, inRoom]); + // Envia as atualizações de apresentação via Broadcast para a sala inteira const handlePresentationChange = (updatedFields: Partial) => { if (!isPresenter || !channelRef.current) return; @@ -600,12 +705,26 @@ export default function MeetingRoom() { }); }; - // Inicializa o Supabase Presence & Broadcast para conexões + // Inicializa o Supabase Presence & Broadcast para conexões oficiais const connectToRoom = (targetRoomId: string) => { if (!name.trim()) { toast.error('Digite seu nome primeiro'); return; } + + // Validar se o avatar já está em uso por outra pessoa + const avatarInUse = Object.values(peers).find((p) => p.avatarId === selectedAvatar); + if (avatarInUse) { + toast.error(`O avatar "${AVATAR_PRESETS[selectedAvatar - 1].name}" já está em uso por ${avatarInUse.username}. Escolha outro!`); + return; + } + + // Validar se a cadeira está ocupada + if (Object.keys(peers).includes(selectedChair)) { + toast.error('O assento selecionado já está ocupado por outro participante. Por favor, selecione outro assento.'); + return; + } + const finalRoomId = targetRoomId.trim().toUpperCase() || Math.random().toString(36).substring(3, 8).toUpperCase(); setRoomCode(finalRoomId); @@ -628,6 +747,7 @@ export default function MeetingRoom() { let count = 0; Object.keys(state).forEach((chairKey) => { + if (chairKey.startsWith('lobby_watcher_')) return; const presences = state[chairKey] as unknown as PeerData[]; if (presences && presences.length > 0) { formattedPeers[chairKey] = presences[0]; @@ -638,9 +758,11 @@ export default function MeetingRoom() { setActiveUsersCount(count); }) .on('presence', { event: 'join' }, ({ key, newPresences }) => { + if (key.startsWith('lobby_watcher_')) return; toast.info(`"${(newPresences[0] as unknown as PeerData)?.username}" sentou-se no assento ${key}`); }) .on('presence', { event: 'leave' }, ({ key, leftPresences }) => { + if (key.startsWith('lobby_watcher_')) return; toast.info(`"${(leftPresences[0] as unknown as PeerData)?.username}" liberou o assento ${key}`); }); @@ -712,9 +834,14 @@ export default function MeetingRoom() { }; const occupiedChairs = useMemo(() => { - return Object.keys(peers); + return Object.keys(peers).filter(k => !k.startsWith('lobby_watcher_')); }, [peers]); + // Checar se o avatar selecionado no lobby está em uso + const isAvatarInUse = useMemo(() => { + return Object.values(peers).find((p) => p.avatarId === selectedAvatar); + }, [peers, selectedAvatar]); + const nextAvatar = () => { setSelectedAvatar((prev) => (prev === 10 ? 1 : prev + 1)); }; @@ -726,6 +853,8 @@ export default function MeetingRoom() { if (!inRoom) { const currentAvatar = AVATAR_PRESETS[selectedAvatar - 1]; + const canJoin = name.trim().length > 0 && !isAvatarInUse && !occupiedChairs.includes(selectedChair); + return (
@@ -741,98 +870,113 @@ export default function MeetingRoom() { Lobby de Reunião Virtual

- Configure suas credenciais e escolha seu visual Mii + Configure suas credenciais e escolha seu avatar

- {/* Avatar Mii Selector (Lado Esquerdo - 5 colunas) */} + {/* Seletor de Avatar (Lado Esquerdo - 5 colunas) */}
- Visual Mii + Visual Avatar + {/* Enquadramento centralizado perfeito (Camera apontando e fov adequado) */}
- + - + - -
- + {currentAvatar.name}

{currentAvatar.desc}

- {/* Nome */} -
- - setName(e.target.value)} - className="font-semibold text-center uppercase font-mono tracking-wider text-xs h-9" - /> -
-
- - {/* Credenciais, Assento e Conexão (Lado Direito - 7 colunas) */} -
- {/* Form de Cargo e Empresa */} -
-
- + {/* Formulário de Identificação: Nome, Cargo e Empresa agrupados sequencialmente na esquerda */} +
+
+ + setName(e.target.value)} + className="font-semibold text-center uppercase font-mono tracking-wider text-xs h-9" + /> +
+ +
+ setCargo(e.target.value)} - className="text-xs h-9" + className="text-xs h-9 text-center" />
-
- + +
+ setEmpresa(e.target.value)} - className="text-xs h-9" + className="text-xs h-9 text-center" />
+
- {/* Seleção do Assento */} + {/* Seleção do Assento, Host e Conexão (Lado Direito - 7 colunas) */} +
+ {/* Seleção do Assento (Exibe quem está ocupando em tempo real) */}
Selecione seu Assento na Mesa -
+
{CHAIRS.map((chair) => { - const isOccupied = occupiedChairs.includes(chair.id); + const occupier = Object.values(peers).find((p) => p.chairId === chair.id); + const isOccupied = !!occupier; const isSelected = selectedChair === chair.id; return ( ); @@ -840,7 +984,7 @@ export default function MeetingRoom() {
- {/* Papel e Conexão */} + {/* Papel e Código de Conexão */}
navigate('/')}> Cancelar -
+ + {isAvatarInUse && ( +

+ * Escolha outro avatar para liberar o acesso. +

+ )}
@@ -892,7 +1046,7 @@ export default function MeetingRoom() { - {/* Renderizar avatares de TODOS na sala (incluindo o próprio usuário para ele se ver sentado na cadeira) */} + {/* Renderizar avatares de TODOS na sala */} {Object.keys(peers).map((chairKey) => { const peer = peers[chairKey]; const chairConf = CHAIRS.find((c) => c.id === peer.chairId);