🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 23:58:44

This commit is contained in:
2026-05-24 23:58:44 +00:00
parent c953db0b26
commit de8558b81c
3 changed files with 837 additions and 1 deletions
+3
View File
@@ -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 = () => (
<Route path="/viewer" element={<Viewer />} />
<Route path="/xr" element={<XRSession />} />
<Route path="/watch/:code" element={<Watch />} />
<Route path="/meeting" element={<MeetingRoom />} />
<Route path="/meeting/:roomId" element={<MeetingRoom />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
+10 -1
View File
@@ -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
</Button>
{/* Enter virtual meeting */}
<Button
className="h-14 w-full gap-3 text-base font-semibold bg-gradient-to-r from-violet-600 to-indigo-600 hover:from-violet-500 hover:to-indigo-500 text-white shadow-lg shadow-violet-500/20 hover:shadow-violet-500/35 border-0 transition-all duration-200"
onClick={() => navigate('/meeting')}>
<Users className="h-5 w-5" />
Reunião Virtual
</Button>
{/* XR Status */}
<div className="flex items-center justify-center gap-2 pt-2">
{xrSupported === null ?
+824
View File
@@ -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<THREE.Group>(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 (
<group ref={groupRef} {...props}>
{/* Nome do Participante acima da cabeça */}
<group position={[0, 0.28, 0]}>
{/* Painel do nome */}
<mesh position={[0, 0, -0.001]}>
<planeGeometry args={[0.22, 0.05]} />
<meshBasicMaterial color="#0f172a" transparent opacity={0.8} />
</mesh>
<Text fontSize={0.024} color="#ffffff" anchorX="center" anchorY="middle" fontStyle="bold">
{username}
</Text>
</group>
{/* Indicador de Fala (anel pulsante) */}
{isTalking && (
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.12, 0]}>
<ringGeometry args={[0.13, 0.15, 32]} />
<meshBasicMaterial color="#3b82f6" transparent opacity={0.6 + Math.sin(Date.now() * 0.01) * 0.3} side={THREE.DoubleSide} />
</mesh>
)}
{/* Renderizadores dos avatares baseados em presets geométricos */}
{avatarId === 1 && (
// Cyber Helmet
<mesh position={[0, 0.1, 0]}>
<octahedronGeometry args={[0.1]} />
<meshStandardMaterial color="#1e293b" roughness={0.2} metalness={0.8} />
<mesh position={[0, 0.02, 0.06]}>
<boxGeometry args={[0.12, 0.025, 0.02]} />
<meshBasicMaterial color={colorObj} />
</mesh>
</mesh>
)}
{avatarId === 2 && (
// Gold Mask
<mesh position={[0, 0.1, 0]} rotation={[0, 0, 0]}>
<dodecahedronGeometry args={[0.095]} />
<meshStandardMaterial color="#b45309" roughness={0.1} metalness={0.9} />
<mesh position={[0, 0, 0.06]}>
<coneGeometry args={[0.03, 0.06, 4]} rotation={[Math.PI / 2, 0, 0]} />
<meshStandardMaterial color="#f59e0b" roughness={0.2} metalness={0.8} />
</mesh>
</mesh>
)}
{avatarId === 3 && (
// Neon Ring
<group position={[0, 0.1, 0]}>
<mesh>
<sphereGeometry args={[0.075, 16, 16]} />
<meshStandardMaterial color="#27272a" roughness={0.5} />
</mesh>
<mesh rotation={[0.4, 0.2, 0.8]}>
<torusGeometry args={[0.1, 0.008, 8, 32]} />
<meshBasicMaterial color={colorObj} />
</mesh>
</group>
)}
{avatarId === 4 && (
// Retro Bot
<group position={[0, 0.1, 0]}>
<mesh>
<boxGeometry args={[0.14, 0.14, 0.12]} />
<meshStandardMaterial color="#3f3f46" roughness={0.4} />
</mesh>
{/* Olhos */}
<mesh position={[-0.04, 0.02, 0.062]}>
<sphereGeometry args={[0.015, 8, 8]} />
<meshBasicMaterial color={colorObj} />
</mesh>
<mesh position={[0.04, 0.02, 0.062]}>
<sphereGeometry args={[0.015, 8, 8]} />
<meshBasicMaterial color={colorObj} />
</mesh>
</group>
)}
{avatarId === 5 && (
// Crystal Prism
<mesh position={[0, 0.1, 0]}>
<coneGeometry args={[0.08, 0.18, 5]} />
<meshStandardMaterial color="#6b21a8" roughness={0.1} metalness={0.7} transparent opacity={0.85} />
<mesh position={[0, -0.02, 0.05]} scale={[1, 0.2, 1]}>
<sphereGeometry args={[0.03]} />
<meshBasicMaterial color={colorObj} />
</mesh>
</mesh>
)}
{avatarId === 6 && (
// Vector Visor
<group position={[0, 0.1, 0]}>
<mesh>
<cylinderGeometry args={[0.065, 0.08, 0.14, 6]} />
<meshStandardMaterial color="#0f172a" roughness={0.2} metalness={0.9} />
</mesh>
<mesh position={[0, 0.03, 0.05]} rotation={[0.2, 0, 0]}>
<boxGeometry args={[0.08, 0.02, 0.04]} />
<meshBasicMaterial color={colorObj} />
</mesh>
</group>
)}
{avatarId === 7 && (
// Quantum Core
<group position={[0, 0.1, 0]}>
<mesh>
<sphereGeometry args={[0.045, 16, 16]} />
<meshBasicMaterial color={colorObj} />
</mesh>
<mesh rotation={[0.5, 0, 0]}>
<torusGeometry args={[0.09, 0.005, 8, 24]} />
<meshBasicMaterial color="#ffffff" transparent opacity={0.8} />
</mesh>
<mesh rotation={[0, 0.5, 1.2]}>
<torusGeometry args={[0.075, 0.005, 8, 24]} />
<meshBasicMaterial color={colorObj} transparent opacity={0.6} />
</mesh>
</group>
)}
{avatarId === 8 && (
// Obsidian Shell
<group position={[0, 0.1, 0]}>
<mesh>
<icosahedronGeometry args={[0.09, 1]} />
<meshStandardMaterial color="#18181b" roughness={0.1} metalness={0.9} flatShading />
</mesh>
<mesh scale={[0.5, 0.5, 0.5]}>
<sphereGeometry args={[0.08]} />
<meshBasicMaterial color={colorObj} />
</mesh>
</group>
)}
{avatarId === 9 && (
// Synth Head
<group position={[0, 0.1, 0]}>
<mesh>
<sphereGeometry args={[0.08, 8, 8]} />
<meshStandardMaterial color="#09090b" roughness={0.5} flatShading />
</mesh>
<mesh position={[0, 0.015, 0.055]}>
<boxGeometry args={[0.13, 0.02, 0.01]} />
<meshBasicMaterial color={colorObj} />
</mesh>
</group>
)}
{avatarId === 10 && (
// Cosmic Helix
<group position={[0, 0.1, 0]}>
<mesh rotation={[0, 0, Date.now() * 0.002]}>
<torusKnotGeometry args={[0.065, 0.015, 32, 4]} />
<meshStandardMaterial color={colorObj} roughness={0.2} metalness={0.7} />
</mesh>
</group>
)}
</group>
);
}
// 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 (
<group>
{/* Piso Industrial */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
<planeGeometry args={[12, 8]} />
<meshStandardMaterial color="#1e293b" roughness={0.85} metalness={0.1} />
</mesh>
{/* Tapete Central */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.001, 0]}>
<planeGeometry args={[4.8, 3.2]} />
<meshStandardMaterial color="#0f172a" roughness={0.9} />
</mesh>
{/* Paredes e Painéis de Fundo */}
<mesh position={[0, 2, -4]}>
<planeGeometry args={[12, 4]} />
<meshStandardMaterial color="#0f172a" roughness={0.9} />
</mesh>
{/* Janelas panorâmicas com vista simulada (gradiente brilhante nas laterais) */}
<mesh position={[-6, 2, 0]} rotation={[0, Math.PI / 2, 0]}>
<planeGeometry args={[8, 4]} />
<meshStandardMaterial color="#1e293b" roughness={0.3} emmissive="#0284c7" />
</mesh>
<mesh position={[6, 2, 0]} rotation={[0, -Math.PI / 2, 0]}>
<planeGeometry args={[8, 4]} />
<meshStandardMaterial color="#1e293b" roughness={0.3} emmissive="#0284c7" />
</mesh>
{/* Mesa Elíptica em U */}
<group position={[0, 0.8, 0]} rotation={[Math.PI / 2, 0, 0]}>
<mesh geometry={tableGeom}>
<meshStandardMaterial color="#451a03" roughness={0.1} metalness={0.15} />
</mesh>
</group>
{/* Pés da mesa (suporte) */}
{[-1.3, 1.3].map((x) =>
[-0.7, 0.7].map((z) => (
<mesh key={`${x}-${z}`} position={[x, 0.4, z]}>
<cylinderGeometry args={[0.04, 0.04, 0.8]} />
<meshStandardMaterial color="#1e293b" metalness={0.8} roughness={0.2} />
</mesh>
))
)}
{/* Projetor de Holograma (Centro da mesa) */}
<group position={[0, 0.78, 0]}>
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[0.26, 0.28, 0.05, 32]} />
<meshStandardMaterial color="#0f172a" metalness={0.9} roughness={0.1} />
</mesh>
{/* Placa metálica brilhante */}
<mesh position={[0, 0.026, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0, 0.22, 32]} />
<meshBasicMaterial color="#0ea5e9" transparent opacity={0.6} />
</mesh>
{/* Feixe de Luz Holográfico */}
<mesh position={[0, 0.45, 0]}>
<cylinderGeometry args={[0.28, 0.2, 0.9, 32, 1, true]} />
<meshBasicMaterial color="#0ea5e9" transparent opacity={0.12} side={THREE.DoubleSide} />
</mesh>
{/* Modelo 3D da Maquete Holográfica (Representação/Pivot) */}
<group position={[0, 0.45, 0]} rotation={[0, Date.now() * 0.00015, 0]}>
{currentActiveModel ? (
<mesh>
<dodecahedronGeometry args={[0.25]} />
<meshStandardMaterial color="#00f5ff" roughness={0.1} metalness={0.8} transparent opacity={0.8} emissive="#0ea5e9" emissiveIntensity={0.6} wireframe />
</mesh>
) : (
// Holograma Padrão (Estrutura geométrica representativa de maquete)
<group>
<mesh position={[0, 0, 0]}>
<octahedronGeometry args={[0.22]} />
<meshStandardMaterial color="#0ea5e9" transparent opacity={0.7} wireframe emissive="#0ea5e9" emissiveIntensity={0.5} />
</mesh>
<mesh position={[0, 0, 0]} rotation={[0, Math.PI / 4, 0]}>
<boxGeometry args={[0.25, 0.04, 0.25]} />
<meshStandardMaterial color="#3b82f6" transparent opacity={0.4} emissive="#3b82f6" emissiveIntensity={0.2} />
</mesh>
</group>
)}
</group>
</group>
{/* Renderizar as Cadeiras Físicas da mesa */}
{CHAIRS.map((chair) => (
<group key={chair.id} position={[chair.pos[0], 0, chair.pos[2]]} rotation={[0, Math.atan2(chair.lookAt[0] - chair.pos[0], chair.lookAt[2] - chair.pos[2]), 0]}>
{/* Base giratória metálica */}
<mesh position={[0, 0.25, 0]}>
<cylinderGeometry args={[0.015, 0.015, 0.5]} />
<meshStandardMaterial color="#71717a" metalness={0.9} roughness={0.1} />
</mesh>
{/* Pés base */}
<mesh position={[0, 0.02, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0, 0.22, 4]} />
<meshStandardMaterial color="#3f3f46" metalness={0.85} roughness={0.2} />
</mesh>
{/* Assento */}
<mesh position={[0, 0.52, 0]}>
<boxGeometry args={[0.28, 0.05, 0.26]} />
<meshStandardMaterial color="#1e293b" roughness={0.7} />
</mesh>
{/* Encosto */}
<mesh position={[0, 0.78, -0.11]} rotation={[0.08, 0, 0]}>
<boxGeometry args={[0.26, 0.44, 0.04]} />
<meshStandardMaterial color="#1e293b" roughness={0.7} />
</mesh>
</group>
))}
{/* Iluminação da Sala */}
<ambientLight intensity={0.4} />
<directionalLight position={[5, 8, 5]} intensity={1.0} castShadow />
<directionalLight position={[-5, 5, -5]} intensity={0.3} />
<pointLight position={[0, 3.5, 0]} intensity={1.5} color="#0ea5e9" distance={6} />
</group>
);
}
// --- 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<Record<string, { username: string; avatarId: number; chairId: string; presence_ref: string }>>({});
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<any>(null);
const chatScrollRef = useRef<HTMLDivElement>(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<string, any> = {};
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 (
<div className="flex min-h-screen flex-col items-center justify-center bg-background grid-industrial p-6">
<div className="absolute top-6 left-6 flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={() => navigate('/')} className="text-muted-foreground hover:text-foreground">
<Home className="h-5 w-5" />
</Button>
<span className="font-mono text-xs uppercase tracking-widest text-muted-foreground">TrackSteelXR</span>
</div>
<div className="w-full max-w-4xl rounded-2xl border border-border bg-card/65 p-6 md:p-10 shadow-2xl backdrop-blur-md animate-in fade-in zoom-in-95 duration-200">
<div className="mb-8 text-center">
<h2 className="text-2xl font-bold tracking-tight text-foreground md:text-3xl">
Lobby de <span className="text-primary">Reunião Virtual</span>
</h2>
<p className="mt-1.5 text-xs font-mono uppercase tracking-wider text-muted-foreground">
Prepare seu avatar e escolha seu assento
</p>
</div>
<div className="grid grid-cols-1 gap-8 md:grid-cols-12">
{/* Seletor de Avatar (Lado Esquerdo - 5 colunas) */}
<div className="flex flex-col items-center justify-center rounded-xl border bg-muted/30 p-6 md:col-span-5 relative">
<span className="absolute top-3 left-4 font-mono text-[9px] uppercase tracking-widest text-muted-foreground">Escolha o Avatar</span>
{/* Modelo Virtual do Avatar no Centro */}
<div className="h-44 w-full flex items-center justify-center relative bg-slate-950/40 rounded-lg overflow-hidden border border-border">
<Canvas camera={{ position: [0, 0.15, 0.35], fov: 50 }}>
<ambientLight intensity={0.5} />
<directionalLight position={[2, 3, 2]} intensity={1.5} />
<pointLight position={[0, 0.15, 0.2]} intensity={1} color={currentAvatar.color} />
<ThreeAvatar avatarId={selectedAvatar} color={currentAvatar.color} username={name || 'Avatar'} position={[0, 0, 0]} isTalking={true} />
</Canvas>
{/* Navegadores de carrossel de avatar */}
<Button variant="ghost" size="icon" className="absolute left-2 h-8 w-8 rounded-full border bg-background/50 hover:bg-background" onClick={prevAvatar}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="absolute right-2 h-8 w-8 rounded-full border bg-background/50 hover:bg-background" onClick={nextAvatar}>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
{/* Informações do avatar */}
<div className="mt-4 text-center">
<span className="font-mono text-xs font-bold uppercase tracking-wider" style={{ color: currentAvatar.color }}>
{currentAvatar.name}
</span>
<p className="text-[10px] text-muted-foreground mt-0.5">{currentAvatar.desc}</p>
</div>
{/* Nome */}
<div className="mt-6 w-full space-y-2">
<label className="font-mono text-[10px] uppercase text-muted-foreground">Seu Nome (Máx 10 letras)</label>
<Input
type="text"
maxLength={10}
placeholder="Ex: Pedro"
value={name}
onChange={(e) => setName(e.target.value)}
className="font-semibold text-center uppercase font-mono tracking-wider"
/>
</div>
</div>
{/* Configurações de Assento e Conexão (Lado Direito - 7 colunas) */}
<div className="flex flex-col justify-between md:col-span-7 space-y-6">
{/* Seleção do Assento */}
<div className="space-y-3">
<span className="font-mono text-[10px] uppercase text-muted-foreground">Selecione seu Assento na Mesa</span>
<div className="grid grid-cols-4 gap-2">
{CHAIRS.map((chair) => {
const isOccupied = occupiedChairs.includes(chair.id);
const isSelected = selectedChair === chair.id;
return (
<Button
key={chair.id}
variant={isSelected ? 'default' : 'outline'}
className={`h-11 flex-col p-1 gap-0.5 text-[10px] font-mono ${
isOccupied ? 'border-destructive/30 text-destructive bg-destructive/5 cursor-not-allowed hover:bg-destructive/5 hover:text-destructive' : ''
}`}
disabled={isOccupied}
onClick={() => setSelectedChair(chair.id)}
>
<span className="font-semibold">{chair.name}</span>
<span className="text-[8px] opacity-75">
{isOccupied ? 'Ocupado' : isSelected ? 'Selecionado' : 'Livre'}
</span>
</Button>
);
})}
</div>
</div>
{/* ID da Sala / Ação */}
<div className="rounded-xl border bg-muted/20 p-4 space-y-4">
<div className="space-y-2">
<label className="font-mono text-[10px] uppercase text-muted-foreground">Código da Sala (Deixe em branco para criar nova)</label>
<div className="flex gap-2">
<Input
type="text"
placeholder="Ex: TS-X9F"
value={inputRoomId}
onChange={(e) => setInputRoomId(e.target.value.toUpperCase())}
className="font-mono tracking-widest font-bold text-center"
/>
</div>
</div>
<div className="flex gap-3 pt-2">
<Button variant="outline" className="flex-1 font-mono text-xs gap-1.5 h-12" onClick={() => navigate('/')}>
Cancelar
</Button>
<Button className="flex-1 glow-primary font-mono text-xs gap-2 h-12" onClick={() => connectToRoom(inputRoomId)}>
Entrar na Reunião <ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
{/* Aviso da Peça Ativa */}
<div className="rounded-lg border border-primary/20 bg-primary/5 p-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="h-2.5 w-2.5 rounded-full bg-primary animate-pulse" />
<span className="font-mono text-[10px] text-foreground">
{activeModel ? `Maquete Ativa: ${activeModel.fileName}` : 'Nenhuma maquete ativa. Sala iniciará com maquete padrão.'}
</span>
</div>
{activeModel && (
<span className="font-mono text-[8px] bg-primary/20 text-primary px-1.5 py-0.5 rounded uppercase">IFC/GLB</span>
)}
</div>
</div>
</div>
</div>
</div>
);
}
// --- Renderização da Sala de Reunião Ativa ---
return (
<div className="flex h-screen overflow-hidden bg-slate-950 text-white relative">
{/* 3D Canvas - Sala Virtual */}
<div className="flex-1 h-full relative">
<Canvas camera={{ position: [0, 2.5, 2.8], fov: 45 }} shadows>
<ThreeMeetingRoomScene currentActiveModel={activeModel} />
{/* 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 (
<ThreeAvatar
key={peer.presence_ref}
avatarId={peer.avatarId}
color={avatarPreset.color}
username={peer.username}
position={chairConf.pos}
isTalking={isTalking && Math.random() > 0.5} // Simulação simples de fala para os peers
/>
);
})}
<OrbitControls
makeDefault
enableDamping
dampingFactor={0.05}
minDistance={0.5}
maxDistance={8}
maxPolarAngle={Math.PI / 2 - 0.05} // Não permite atravessar o chão
target={[0, 0.95, 0]} // Foca no centro da mesa
/>
</Canvas>
{/* HUD Superior / Detalhes da Conexão */}
<div className="absolute top-4 left-4 right-4 pointer-events-none flex justify-between items-start z-10">
<div className="pointer-events-auto flex items-center gap-2 rounded-lg border border-border bg-slate-900/90 backdrop-blur-md px-3.5 py-2 shadow-2xl">
<Users className="h-4 w-4 text-primary" />
<div className="flex flex-col">
<span className="font-mono text-[9px] text-muted-foreground uppercase">Reunião Conectada</span>
<span className="font-mono text-xs font-semibold text-foreground">
Cadeiras Ocupadas: {activeUsersCount}/8
</span>
</div>
</div>
<div className="pointer-events-auto flex items-center gap-3">
{/* Código da sala */}
<div className="flex items-center gap-1.5 rounded-lg border border-border bg-slate-900/90 backdrop-blur-md px-3 py-1.5 shadow-2xl">
<span className="font-mono text-[10px] text-muted-foreground">CÓDIGO:</span>
<span className="font-mono text-xs font-bold text-primary tracking-widest">{roomCode}</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={() => {
navigator.clipboard.writeText(window.location.href);
toast.success('Link do convite copiado!');
}}
title="Copiar link do convite"
>
<Share2 className="h-3 w-3" />
</Button>
</div>
{/* Sair */}
<Button variant="destructive" size="sm" className="font-mono text-[10px] gap-1.5 h-8 px-3" onClick={handleLeaveRoom}>
<LogOut className="h-3.5 w-3.5" /> Sair
</Button>
</div>
</div>
{/* HUD Inferior - Controles de Áudio e Instrução */}
<div className="absolute bottom-4 left-4 pointer-events-none z-10 flex gap-2">
<Button
variant={isMuted ? 'destructive' : 'default'}
size="icon"
className="pointer-events-auto h-10 w-10 rounded-xl shadow-2xl border"
onClick={() => setIsMuted(!isMuted)}
title={isMuted ? 'Ativar microfone' : 'Mutar microfone'}
>
{isMuted ? <MicOff className="h-4.5 w-4.5" /> : <Mic className="h-4.5 w-4.5" />}
</Button>
<div className="pointer-events-auto rounded-xl border bg-slate-900/90 backdrop-blur-md px-4 py-2 flex flex-col justify-center shadow-2xl">
<span className="font-mono text-[8px] text-muted-foreground uppercase">Dica Imersiva</span>
<span className="text-[10px] text-foreground font-mono">
Clique e arraste com o botão esquerdo do mouse para girar a maquete e a sala
</span>
</div>
</div>
</div>
{/* Painel Lateral Direito - Chat e Lista de Usuários (Largura 320px) */}
<div className="w-80 border-l border-slate-800 bg-slate-900/95 flex flex-col h-full z-10 shadow-2xl">
{/* Header do Painel */}
<div className="border-b border-slate-800 p-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<MessageSquare className="h-4.5 w-4.5 text-primary" />
<span className="font-mono text-xs font-bold uppercase tracking-wider">Chat & Notas</span>
</div>
<span className="font-mono text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">Realtime</span>
</div>
{/* Histórico do Chat */}
<div ref={chatScrollRef} className="flex-1 overflow-y-auto p-4 space-y-3 scrollbar-thin">
{chatMessages.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-slate-500 text-center px-4 space-y-2">
<MessageSquare className="h-8 w-8 opacity-30 text-primary" />
<p className="text-[10px] font-mono leading-relaxed">
Nenhuma mensagem enviada. Seja o primeiro a digitar no chat da reunião!
</p>
</div>
) : (
chatMessages.map((msg) => (
<div key={msg.id} className="space-y-0.5">
<div className="flex items-baseline justify-between">
<span className="font-mono text-[10px] font-bold text-primary uppercase">{msg.sender}</span>
<span className="text-[8px] text-slate-500 font-mono">{msg.time}</span>
</div>
<p className="text-xs bg-slate-800/60 border border-slate-700/30 rounded-lg p-2 leading-relaxed text-slate-300">
{msg.text}
</p>
</div>
))
)}
</div>
{/* Input do Chat */}
<div className="p-3 border-t border-slate-800 bg-slate-900 flex gap-2">
<Input
type="text"
placeholder="Digite sua mensagem..."
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSendMessage();
}}
className="flex-1 text-xs border-slate-800 bg-slate-950/65"
/>
<Button variant="default" size="icon" className="h-9 w-9 bg-primary" onClick={handleSendMessage}>
<Send className="h-3.5 w-3.5" />
</Button>
</div>
</div>
</div>
);
}