diff --git a/src/App.tsx b/src/App.tsx index 42fbbb6..0c0ca1b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import Index from "./pages/Index"; import Viewer from "./pages/Viewer"; import XRSession from "./pages/XRSession"; import Watch from "./pages/Watch"; +import MeetingRoom from "./pages/MeetingRoom"; import NotFound from "./pages/NotFound"; import "@/lib/remoteLogger"; @@ -23,6 +24,8 @@ const App = () => ( } /> } /> } /> + } /> + } /> } /> diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index c020dcc..4056262 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useCallback, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Upload, Glasses, CheckCircle, XCircle, Loader2, Box, Package } from 'lucide-react'; +import { Upload, Glasses, CheckCircle, XCircle, Loader2, Box, Package, Users } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { useModelStore } from '@/stores/useModelStore'; import { toast } from 'sonner'; @@ -304,6 +304,15 @@ const Index = () => { Visualizar Modelo 3D + {/* Enter virtual meeting */} + + {/* XR Status */}
{xrSupported === null ? diff --git a/src/pages/MeetingRoom.tsx b/src/pages/MeetingRoom.tsx new file mode 100644 index 0000000..922dded --- /dev/null +++ b/src/pages/MeetingRoom.tsx @@ -0,0 +1,824 @@ +import { useEffect, useRef, useState, useMemo } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { Canvas, useFrame, useThree } from '@react-three/fiber'; +import { OrbitControls, Text, Line } from '@react-three/drei'; +import * as THREE from 'three'; +import { supabase } from '@/integrations/supabase/client'; +import { useModelStore } from '@/stores/useModelStore'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { toast } from 'sonner'; +import { + Users, MessageSquare, Mic, MicOff, Home, Send, + ArrowRight, Check, ChevronLeft, ChevronRight, Share2, LogOut +} from 'lucide-react'; + +// --- Configurações da Mesa de Reunião --- +// Coordenadas das 8 cadeiras ao redor da mesa elíptica +const CHAIRS = [ + { id: '1', name: 'Assento 1', pos: [-1.2, 0.95, -0.6], lookAt: [0, 0.95, 0] }, + { id: '2', name: 'Assento 2', pos: [-0.6, 0.95, -0.9], lookAt: [0, 0.95, 0] }, + { id: '3', name: 'Assento 3', pos: [0.6, 0.95, -0.9], lookAt: [0, 0.95, 0] }, + { id: '4', name: 'Assento 4', pos: [1.2, 0.95, -0.6], lookAt: [0, 0.95, 0] }, + { id: '5', name: 'Assento 5', pos: [1.2, 0.95, 0.6], lookAt: [0, 0.95, 0] }, + { id: '6', name: 'Assento 6', pos: [0.6, 0.95, 0.9], lookAt: [0, 0.95, 0] }, + { id: '7', name: 'Assento 7', pos: [-0.6, 0.95, 0.9], lookAt: [0, 0.95, 0] }, + { id: '8', name: 'Assento 8', pos: [-1.2, 0.95, 0.6], lookAt: [0, 0.95, 0] }, +]; + +// 10 Modelos de Avatar Futurista (representações no Lobby) +const AVATAR_PRESETS = [ + { id: 1, name: 'Cyber Helmet', desc: 'Elmo octaédrico com visor ciano', color: '#0ea5e9' }, + { id: 2, name: 'Gold Mask', desc: 'Máscara poliédrica dourada', color: '#eab308' }, + { id: 3, name: 'Neon Ring', desc: 'Esfera com órbita luminosa', color: '#ec4899' }, + { id: 4, name: 'Retro Bot', desc: 'Unidade cúbica com visor esmeralda', color: '#10b981' }, + { id: 5, name: 'Crystal Prism', desc: 'Estrutura geométrica ametista', color: '#a855f7' }, + { id: 6, name: 'Vector Visor', desc: 'Busto metálico com LED vermelho', color: '#ef4444' }, + { id: 7, name: 'Quantum Core', desc: 'Núcleo de energia flutuante', color: '#00f5ff' }, + { id: 8, name: 'Obsidian Shell', desc: 'Casca geométrica com núcleo laranja', color: '#f97316' }, + { id: 9, name: 'Helix Guardian', desc: 'Espiral cósmica dupla', color: '#3b82f6' }, + { id: 10, name: 'Carbon Apex', desc: 'Estrutura furtiva em cinza grafite', color: '#64748b' }, +]; + +// --- Subcomponentes 3D do Cenário --- + +// Avatar Tridimensional Renderizado +function ThreeAvatar({ avatarId, color, isTalking, username, ...props }: { avatarId: number; color: string; isTalking?: boolean; username: string } & any) { + const groupRef = useRef(null); + const colorObj = useMemo(() => new THREE.Color(color), [color]); + + useFrame((state) => { + if (groupRef.current) { + // Pequena oscilação flutuante sutil + groupRef.current.position.y = props.position[1] + Math.sin(state.clock.getElapsedTime() * 2 + avatarId) * 0.015; + } + }); + + return ( + + {/* Nome do Participante acima da cabeça */} + + {/* Painel do nome */} + + + + + + {username} + + + + {/* Indicador de Fala (anel pulsante) */} + {isTalking && ( + + + + + )} + + {/* Renderizadores dos avatares baseados em presets geométricos */} + {avatarId === 1 && ( + // Cyber Helmet + + + + + + + + + )} + + {avatarId === 2 && ( + // Gold Mask + + + + + + + + + )} + + {avatarId === 3 && ( + // Neon Ring + + + + + + + + + + + )} + + {avatarId === 4 && ( + // Retro Bot + + + + + + {/* Olhos */} + + + + + + + + + + )} + + {avatarId === 5 && ( + // Crystal Prism + + + + + + + + + )} + + {avatarId === 6 && ( + // Vector Visor + + + + + + + + + + + )} + + {avatarId === 7 && ( + // Quantum Core + + + + + + + + + + + + + + + )} + + {avatarId === 8 && ( + // Obsidian Shell + + + + + + + + + + + )} + + {avatarId === 9 && ( + // Synth Head + + + + + + + + + + + )} + + {avatarId === 10 && ( + // Cosmic Helix + + + + + + + )} + + ); +} + +// Mesa, Cadeiras e Detalhes da Sala de Reunião +function ThreeMeetingRoomScene({ currentActiveModel }: { currentActiveModel: any }) { + const tableGeom = useMemo(() => { + // Geometria da mesa em U + const shape = new THREE.Shape(); + shape.moveTo(-1.6, -1.1); + shape.lineTo(1.6, -1.1); + shape.quadraticCurveTo(1.9, -1.1, 1.9, -0.8); + shape.lineTo(1.9, 0.8); + shape.quadraticCurveTo(1.9, 1.1, 1.6, 1.1); + shape.lineTo(-1.6, 1.1); + shape.quadraticCurveTo(-1.9, 1.1, -1.9, 0.8); + shape.lineTo(-1.9, -0.8); + shape.quadraticCurveTo(-1.9, -1.1, -1.6, -1.1); + + // Furo elíptico central para projetar o holograma + const hole = new THREE.Path(); + hole.absellipse(0, 0, 0.9, 0.45, 0, Math.PI * 2, true); + shape.holes.push(hole); + + return new THREE.ExtrudeGeometry(shape, { depth: 0.06, bevelEnabled: true, bevelThickness: 0.01, bevelSize: 0.01, bevelSegments: 3 }); + }, []); + + return ( + + {/* Piso Industrial */} + + + + + + {/* Tapete Central */} + + + + + + {/* Paredes e Painéis de Fundo */} + + + + + {/* Janelas panorâmicas com vista simulada (gradiente brilhante nas laterais) */} + + + + + + + + + + {/* Mesa Elíptica em U */} + + + + + + + {/* Pés da mesa (suporte) */} + {[-1.3, 1.3].map((x) => + [-0.7, 0.7].map((z) => ( + + + + + )) + )} + + {/* Projetor de Holograma (Centro da mesa) */} + + + + + + {/* Placa metálica brilhante */} + + + + + {/* Feixe de Luz Holográfico */} + + + + + + {/* Modelo 3D da Maquete Holográfica (Representação/Pivot) */} + + {currentActiveModel ? ( + + + + + ) : ( + // Holograma Padrão (Estrutura geométrica representativa de maquete) + + + + + + + + + + + )} + + + + {/* Renderizar as Cadeiras Físicas da mesa */} + {CHAIRS.map((chair) => ( + + {/* Base giratória metálica */} + + + + + {/* Pés base */} + + + + + {/* Assento */} + + + + + {/* Encosto */} + + + + + + ))} + + {/* Iluminação da Sala */} + + + + + + ); +} + +// --- Componente Principal da Tela --- + +export default function MeetingRoom() { + const { roomId } = useParams<{ roomId?: string }>(); + const navigate = useNavigate(); + const activeModel = useModelStore((s) => s.model); + + // Estados do Lobby + const [inRoom, setInRoom] = useState(false); + const [name, setName] = useState(''); + const [selectedAvatar, setSelectedAvatar] = useState(1); + const [selectedChair, setSelectedChair] = useState('1'); + const [inputRoomId, setInputRoomId] = useState(roomId || ''); + + // Estados da Sala + const [peers, setPeers] = useState>({}); + const [chatMessages, setChatMessages] = useState<{ id: string; sender: string; text: string; time: string }[]>([]); + const [newMessage, setNewMessage] = useState(''); + const [isMuted, setIsMuted] = useState(false); + const [isTalking, setIsTalking] = useState(false); + const [activeUsersCount, setActiveUsersCount] = useState(1); + const [roomCode, setRoomCode] = useState(roomId || ''); + + const channelRef = useRef(null); + const chatScrollRef = useRef(null); + + // Gerencia o loop de simulação de fala + useEffect(() => { + if (!inRoom || isMuted) { + setIsTalking(false); + return; + } + const interval = setInterval(() => { + // Simula detecção de microfone com probabilidade de atividade + setIsTalking(Math.random() > 0.7); + }, 1500); + return () => clearInterval(interval); + }, [inRoom, isMuted]); + + // Rola o chat para o fim quando houver mensagens + useEffect(() => { + if (chatScrollRef.current) { + chatScrollRef.current.scrollTop = chatScrollRef.current.scrollHeight; + } + }, [chatMessages]); + + // Inicializa o Supabase Presence & Broadcast para conexões + const connectToRoom = (targetRoomId: string) => { + if (!name.trim()) { + toast.error('Digite seu nome primeiro'); + return; + } + const finalRoomId = targetRoomId.trim().toUpperCase() || Math.random().toString(36).substring(3, 8).toUpperCase(); + setRoomCode(finalRoomId); + + console.log(`🔌 [MeetingRoom] Conectando à sala: ${finalRoomId}`); + + const channel = supabase.channel(`meeting_room_${finalRoomId}`, { + config: { + broadcast: { self: true }, + presence: { key: selectedChair }, + }, + }); + + channelRef.current = channel; + + // Sincroniza participantes + channel + .on('presence', { event: 'sync' }, () => { + const state = channel.presenceState(); + const formattedPeers: Record = {}; + let count = 0; + + Object.keys(state).forEach((chairKey) => { + const presences = state[chairKey] as any[]; + if (presences && presences.length > 0) { + formattedPeers[chairKey] = presences[0]; + count++; + } + }); + setPeers(formattedPeers); + setActiveUsersCount(count); + }) + .on('presence', { event: 'join' }, ({ key, newPresences }) => { + toast.info(`Participante "${newPresences[0]?.username}" entrou no assento ${key}`); + }) + .on('presence', { event: 'leave' }, ({ key, leftPresences }) => { + toast.info(`Participante "${leftPresences[0]?.username}" desocupou o assento ${key}`); + }); + + // Escuta mensagens do Chat via Broadcast + channel.on('broadcast', { event: 'chat_msg' }, (payload) => { + setChatMessages((prev) => [ + ...prev, + { + id: Math.random().toString(), + sender: payload.payload.sender, + text: payload.payload.text, + time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), + }, + ]); + }); + + // Inscrever no WebSocket + channel.subscribe(async (status) => { + if (status === 'SUBSCRIBED') { + // Envia as informações do usuário atual para a presença + await channel.track({ + username: name.substring(0, 10), + avatarId: selectedAvatar, + chairId: selectedChair, + joinedAt: new Date().toISOString(), + }); + setInRoom(true); + toast.success(`Entrou na sala de reunião: ${finalRoomId}`); + navigate(`/meeting/${finalRoomId}`, { replace: true }); + } else if (status === 'CHANNEL_ERROR') { + toast.error('Erro de conexão ao canal de reuniões.'); + } + }); + }; + + // Enviar Mensagem de Chat + const handleSendMessage = () => { + if (!newMessage.trim() || !channelRef.current) return; + channelRef.current.send({ + type: 'broadcast', + event: 'chat_msg', + payload: { + sender: name.substring(0, 10), + text: newMessage.trim(), + }, + }); + setNewMessage(''); + }; + + // Sair da Sala de Reunião + const handleLeaveRoom = async () => { + if (channelRef.current) { + console.log('🔌 [MeetingRoom] Saindo da sala...'); + await supabase.removeChannel(channelRef.current); + } + setInRoom(false); + setPeers({}); + setChatMessages([]); + navigate('/'); + }; + + // Identifica quais cadeiras já estão ocupadas por outros participantes + const occupiedChairs = useMemo(() => { + return Object.keys(peers); + }, [peers]); + + // Escolha do Preset de Avatar no Lobby (Navegação) + const nextAvatar = () => { + setSelectedAvatar((prev) => (prev === 10 ? 1 : prev + 1)); + }; + const prevAvatar = () => { + setSelectedAvatar((prev) => (prev === 1 ? 10 : prev - 1)); + }; + + // --- Renderização do Lobby --- + + if (!inRoom) { + const currentAvatar = AVATAR_PRESETS[selectedAvatar - 1]; + return ( +
+
+ + TrackSteelXR +
+ +
+
+

+ Lobby de Reunião Virtual +

+

+ Prepare seu avatar e escolha seu assento +

+
+ +
+ {/* Seletor de Avatar (Lado Esquerdo - 5 colunas) */} +
+ Escolha o Avatar + + {/* Modelo Virtual do Avatar no Centro */} +
+ + + + + + + + {/* Navegadores de carrossel de avatar */} + + +
+ + {/* Informações do avatar */} +
+ + {currentAvatar.name} + +

{currentAvatar.desc}

+
+ + {/* Nome */} +
+ + setName(e.target.value)} + className="font-semibold text-center uppercase font-mono tracking-wider" + /> +
+
+ + {/* Configurações de Assento e Conexão (Lado Direito - 7 colunas) */} +
+ {/* Seleção do Assento */} +
+ Selecione seu Assento na Mesa +
+ {CHAIRS.map((chair) => { + const isOccupied = occupiedChairs.includes(chair.id); + const isSelected = selectedChair === chair.id; + return ( + + ); + })} +
+
+ + {/* ID da Sala / Ação */} +
+
+ +
+ setInputRoomId(e.target.value.toUpperCase())} + className="font-mono tracking-widest font-bold text-center" + /> +
+
+ +
+ + +
+
+ + {/* Aviso da Peça Ativa */} +
+
+ + + {activeModel ? `Maquete Ativa: ${activeModel.fileName}` : 'Nenhuma maquete ativa. Sala iniciará com maquete padrão.'} + +
+ {activeModel && ( + IFC/GLB + )} +
+
+
+
+
+ ); + } + + // --- Renderização da Sala de Reunião Ativa --- + + return ( +
+ {/* 3D Canvas - Sala Virtual */} +
+ + + + {/* Renderizar avatares dos outros participantes na sala */} + {Object.keys(peers).map((chairKey) => { + const peer = peers[chairKey]; + const chairConf = CHAIRS.find((c) => c.id === peer.chairId); + if (!chairConf) return null; + + // Não renderiza a nós mesmos na cena como avatar, nossa câmera já está na cadeira! + if (peer.chairId === selectedChair) return null; + + const avatarPreset = AVATAR_PRESETS[peer.avatarId - 1] || AVATAR_PRESETS[0]; + + return ( + 0.5} // Simulação simples de fala para os peers + /> + ); + })} + + + + + {/* HUD Superior / Detalhes da Conexão */} +
+
+ +
+ Reunião Conectada + + Cadeiras Ocupadas: {activeUsersCount}/8 + +
+
+ +
+ {/* Código da sala */} +
+ CÓDIGO: + {roomCode} + +
+ + {/* Sair */} + +
+
+ + {/* HUD Inferior - Controles de Áudio e Instrução */} +
+ + +
+ Dica Imersiva + + Clique e arraste com o botão esquerdo do mouse para girar a maquete e a sala + +
+
+
+ + {/* Painel Lateral Direito - Chat e Lista de Usuários (Largura 320px) */} +
+ {/* Header do Painel */} +
+
+ + Chat & Notas +
+ Realtime +
+ + {/* Histórico do Chat */} +
+ {chatMessages.length === 0 ? ( +
+ +

+ Nenhuma mensagem enviada. Seja o primeiro a digitar no chat da reunião! +

+
+ ) : ( + chatMessages.map((msg) => ( +
+
+ {msg.sender} + {msg.time} +
+

+ {msg.text} +

+
+ )) + )} +
+ + {/* Input do Chat */} +
+ setNewMessage(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSendMessage(); + }} + className="flex-1 text-xs border-slate-800 bg-slate-950/65" + /> + +
+
+
+ ); +}