🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 00:19:23

This commit is contained in:
2026-05-25 00:19:23 +00:00
parent f38bf58bd2
commit e390fd0f59
+446 -129
View File
@@ -1,34 +1,38 @@
import { useEffect, useRef, useState, useMemo, Suspense } from 'react'; import { useEffect, useRef, useState, useMemo, Suspense } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { Canvas, useFrame } from '@react-three/fiber'; import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { OrbitControls, Text, useGLTF } from '@react-three/drei'; import { OrbitControls, Text, useGLTF } from '@react-three/drei';
import * as THREE from 'three'; import * as THREE from 'three';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { useModelStore } from '@/stores/useModelStore'; import { useModelStore } from '@/stores/useModelStore';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
Users, MessageSquare, Mic, MicOff, Home, Send, Users, MessageSquare, Mic, MicOff, Home, Send, Slider,
ArrowRight, ChevronLeft, ChevronRight, Share2, LogOut ArrowRight, ChevronLeft, ChevronRight, Share2, LogOut, Check,
Scissors, RotateCw, Maximize2, ShieldAlert, Award
} from 'lucide-react'; } from 'lucide-react';
// --- Configurações da Mesa de Reunião --- // --- Configurações da Mesa de Reunião ---
// Coordenadas das 8 cadeiras ao redor da mesa elíptica // Coordenadas das 8 cadeiras posicionadas organizadamente FORA da mesa
// A mesa vai de X = -1.9 a 1.9 e Z = -1.1 a 1.1. As cadeiras ficam em Z = -1.45 e Z = 1.45.
const CHAIRS = [ const CHAIRS = [
{ id: '1', name: 'Assento 1', pos: [-1.2, 0.95, -0.6], lookAt: [0, 0.95, 0] }, { id: '1', name: 'Assento 1', pos: [-1.2, 0.95, -1.45], lookAt: [0, 0.95, 0] },
{ id: '2', name: 'Assento 2', pos: [-0.6, 0.95, -0.9], lookAt: [0, 0.95, 0] }, { id: '2', name: 'Assento 2', pos: [-0.4, 0.95, -1.45], lookAt: [0, 0.95, 0] },
{ id: '3', name: 'Assento 3', pos: [0.6, 0.95, -0.9], lookAt: [0, 0.95, 0] }, { id: '3', name: 'Assento 3', pos: [0.4, 0.95, -1.45], lookAt: [0, 0.95, 0] },
{ id: '4', name: 'Assento 4', pos: [1.2, 0.95, -0.6], lookAt: [0, 0.95, 0] }, { id: '4', name: 'Assento 4', pos: [1.2, 0.95, -1.45], lookAt: [0, 0.95, 0] },
{ id: '5', name: 'Assento 5', pos: [1.2, 0.95, 0.6], lookAt: [0, 0.95, 0] }, { id: '5', name: 'Assento 5', pos: [1.2, 0.95, 1.45], lookAt: [0, 0.95, 0] },
{ id: '6', name: 'Assento 6', pos: [0.6, 0.95, 0.9], lookAt: [0, 0.95, 0] }, { id: '6', name: 'Assento 6', pos: [0.4, 0.95, 1.45], lookAt: [0, 0.95, 0] },
{ id: '7', name: 'Assento 7', pos: [-0.6, 0.95, 0.9], lookAt: [0, 0.95, 0] }, { id: '7', name: 'Assento 7', pos: [-0.4, 0.95, 1.45], lookAt: [0, 0.95, 0] },
{ id: '8', name: 'Assento 8', pos: [-1.2, 0.95, 0.6], lookAt: [0, 0.95, 0] }, { id: '8', name: 'Assento 8', pos: [-1.2, 0.95, 1.45], lookAt: [0, 0.95, 0] },
]; ];
// 10 Modelos de Avatar estilo Nintendo Wii (Mii) com características humanas // 10 Modelos de Mii com características e cores de cabelo humanas
const AVATAR_PRESETS = [ const AVATAR_PRESETS = [
{ id: 1, name: 'Mii Cyber', desc: 'Pele clara com óculos vermelhos e cabelo curto', color: '#ef4444' }, { 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: 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: 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: 4, name: 'Mii Sporty', desc: 'Boné azul virado para trás e sorriso aberto', color: '#3b82f6' },
@@ -40,7 +44,7 @@ const AVATAR_PRESETS = [
{ id: 10, name: 'Mii Straw', desc: 'Chapéu de palha do campo clássico', color: '#ca8a04' }, { id: 10, name: 'Mii Straw', desc: 'Chapéu de palha do campo clássico', color: '#ca8a04' },
]; ];
// Cores de pele padrão para os Miis // Cores de pele Mii
const SKIN_COLORS = ['#fed7aa', '#fdba74', '#f59e0b', '#d97706', '#9a3412', '#ffedd5']; const SKIN_COLORS = ['#fed7aa', '#fdba74', '#f59e0b', '#d97706', '#9a3412', '#ffedd5'];
// --- Subcomponentes 3D --- // --- Subcomponentes 3D ---
@@ -49,7 +53,7 @@ const SKIN_COLORS = ['#fed7aa', '#fdba74', '#f59e0b', '#d97706', '#9a3412', '#ff
function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: number; username: string; isTalking?: boolean } & any) { function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: number; username: string; isTalking?: boolean } & any) {
const groupRef = useRef<THREE.Group>(null); const groupRef = useRef<THREE.Group>(null);
// Parâmetros do rosto e cabelo com base no ID selecionado // Parâmetros do rosto e cabelo com base no ID
const mii = useMemo(() => { const mii = useMemo(() => {
const skin = SKIN_COLORS[(avatarId - 1) % SKIN_COLORS.length]; const skin = SKIN_COLORS[(avatarId - 1) % SKIN_COLORS.length];
return { return {
@@ -68,14 +72,13 @@ function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: numb
useFrame((state) => { useFrame((state) => {
if (groupRef.current) { if (groupRef.current) {
// Pequena flutuação para dar vida
groupRef.current.position.y = props.position[1] + Math.sin(state.clock.getElapsedTime() * 2 + avatarId) * 0.012; groupRef.current.position.y = props.position[1] + Math.sin(state.clock.getElapsedTime() * 2 + avatarId) * 0.012;
} }
}); });
return ( return (
<group ref={groupRef} {...props}> <group ref={groupRef} {...props}>
{/* Balão com Nome acima da cabeça */} {/* Balão de Nome acima da cabeça */}
<group position={[0, 0.28, 0]}> <group position={[0, 0.28, 0]}>
<mesh position={[0, 0, -0.001]}> <mesh position={[0, 0, -0.001]}>
<planeGeometry args={[0.22, 0.05]} /> <planeGeometry args={[0.22, 0.05]} />
@@ -185,7 +188,7 @@ function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: numb
<meshStandardMaterial color={mii.skin} roughness={0.6} /> <meshStandardMaterial color={mii.skin} roughness={0.6} />
</mesh> </mesh>
{/* BOCA (Sorriso) */} {/* BOCA */}
<mesh position={[0, 0.065, 0.073]} rotation={[0.1, 0, 0]} scale={[1, 0.75, 1]}> <mesh position={[0, 0.065, 0.073]} rotation={[0.1, 0, 0]} scale={[1, 0.75, 1]}>
<torusGeometry args={[0.015, 0.004, 4, 16, Math.PI]} /> <torusGeometry args={[0.015, 0.004, 4, 16, Math.PI]} />
<meshBasicMaterial color="#b91c1c" /> <meshBasicMaterial color="#b91c1c" />
@@ -231,39 +234,99 @@ function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: numb
); );
} }
// Carregador e Escalador Inteligente do Modelo Real (Adaptado para 1 Metro de tamanho) // Carregador e Escalador Inteligente da Maquete Holográfica
function HologramModel({ model }: { model: { url: string } }) { // Sincroniza transformações de rotação, escala e planos de corte do Apresentador
function HologramModel({ model, presentationState }: { model: { url: string }; presentationState: any }) {
const { scene } = useGLTF(model.url); const { scene } = useGLTF(model.url);
const clone = useMemo(() => scene.clone(), [scene]); const clone = useMemo(() => scene.clone(), [scene]);
const groupRef = useRef<THREE.Group>(null); const groupRef = useRef<THREE.Group>(null);
// Calcula escala de aproximadamente 1 metro para caber na mesa
useEffect(() => { useEffect(() => {
if (!groupRef.current) return; if (!groupRef.current) return;
// Medir limites tridimensionais da maquete importada
const box = new THREE.Box3().setFromObject(clone); const box = new THREE.Box3().setFromObject(clone);
const size = new THREE.Vector3(); const size = new THREE.Vector3();
box.getSize(size); box.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z); const maxDim = Math.max(size.x, size.y, size.z);
if (maxDim > 0) { if (maxDim > 0) {
// Ajusta a escala de forma inteligente para ter aproximadamente 1 metro (ex: 0.95m)
const targetSize = 0.95; const targetSize = 0.95;
const scaleFactor = targetSize / maxDim; const scaleFactor = targetSize / maxDim;
groupRef.current.scale.setScalar(scaleFactor); groupRef.current.scale.setScalar(scaleFactor);
// Centralizar o pivot em (0,0,0)
const center = new THREE.Vector3(); const center = new THREE.Vector3();
box.getCenter(center); box.getCenter(center);
// Centraliza e levanta ligeiramente acima da mesa
clone.position.copy(center).multiplyScalar(-scaleFactor); clone.position.copy(center).multiplyScalar(-scaleFactor);
clone.position.y += 0.02; clone.position.y += 0.02;
} }
}, [clone]); }, [clone]);
// Aplica transformações de Rotação e Escala compartilhadas
useFrame(() => {
if (groupRef.current && presentationState) {
const { rotation, scale } = presentationState;
if (rotation) {
groupRef.current.rotation.x = rotation[0];
groupRef.current.rotation.y = rotation[1];
groupRef.current.rotation.z = rotation[2];
}
if (scale) {
// Multiplica a escala base inteligente pelo fator da apresentação
const box = new THREE.Box3().setFromObject(clone);
const size = new THREE.Vector3();
box.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z);
if (maxDim > 0) {
const baseScale = 0.95 / maxDim;
groupRef.current.scale.setScalar(baseScale * scale);
}
}
}
});
// Aplica Planos de Corte (clippingPlanes) nos materiais
useEffect(() => {
if (!clone || !presentationState) return;
const { sectionEnabled, sectionAxis, sectionLevel, sectionInvert } = presentationState;
if (!sectionEnabled) {
clone.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach((mat) => {
if (mat) {
mat.clippingPlanes = [];
mat.needsUpdate = true;
}
});
}
});
return;
}
// Vetor normal baseado no eixo e na inversão do corte
const normal = new THREE.Vector3();
if (sectionAxis === 'x') normal.set(sectionInvert ? -1 : 1, 0, 0);
else if (sectionAxis === 'y') normal.set(0, sectionInvert ? -1 : 1, 0);
else if (sectionAxis === 'z') normal.set(0, 0, sectionInvert ? -1 : 1);
// O plano corta na coordenada desejada
const plane = new THREE.Plane(normal, sectionLevel);
clone.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
mats.forEach((mat) => {
if (mat) {
mat.clippingPlanes = [plane];
mat.clipShadows = true;
mat.needsUpdate = true;
}
});
}
});
}, [clone, presentationState]);
return ( return (
<group ref={groupRef}> <group ref={groupRef}>
<primitive object={clone} /> <primitive object={clone} />
@@ -271,8 +334,8 @@ function HologramModel({ model }: { model: { url: string } }) {
); );
} }
// Cenário Claro, Limpo e Iluminado da Sala de Reunião // Cenário da Sala de Reunião Clara
function ThreeMeetingRoomScene({ currentActiveModel }: { currentActiveModel: any }) { function ThreeMeetingRoomScene({ currentActiveModel, presentationState }: { currentActiveModel: any; presentationState: any }) {
const tableGeom = useMemo(() => { const tableGeom = useMemo(() => {
// Geometria da mesa em U // Geometria da mesa em U
const shape = new THREE.Shape(); const shape = new THREE.Shape();
@@ -286,7 +349,6 @@ function ThreeMeetingRoomScene({ currentActiveModel }: { currentActiveModel: any
shape.lineTo(-1.9, -0.8); shape.lineTo(-1.9, -0.8);
shape.quadraticCurveTo(-1.9, -1.1, -1.6, -1.1); shape.quadraticCurveTo(-1.9, -1.1, -1.6, -1.1);
// Furo elíptico central para projetar o holograma
const hole = new THREE.Path(); const hole = new THREE.Path();
hole.absellipse(0, 0, 0.9, 0.45, 0, Math.PI * 2, true); hole.absellipse(0, 0, 0.9, 0.45, 0, Math.PI * 2, true);
shape.holes.push(hole); shape.holes.push(hole);
@@ -296,7 +358,7 @@ function ThreeMeetingRoomScene({ currentActiveModel }: { currentActiveModel: any
return ( return (
<group> <group>
{/* Piso de Laca Clara (Sala muito mais clara e iluminada!) */} {/* Piso Clara */}
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]}> <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
<planeGeometry args={[12, 8]} /> <planeGeometry args={[12, 8]} />
<meshStandardMaterial color="#e2e8f0" roughness={0.4} metalness={0.1} /> <meshStandardMaterial color="#e2e8f0" roughness={0.4} metalness={0.1} />
@@ -313,7 +375,7 @@ function ThreeMeetingRoomScene({ currentActiveModel }: { currentActiveModel: any
<planeGeometry args={[12, 4]} /> <planeGeometry args={[12, 4]} />
<meshStandardMaterial color="#f1f5f9" roughness={0.9} /> <meshStandardMaterial color="#f1f5f9" roughness={0.9} />
</mesh> </mesh>
{/* Janelas Panorâmicas Ensolaradas */} {/* Janelas Panorâmicas */}
<mesh position={[-6, 2, 0]} rotation={[0, Math.PI / 2, 0]}> <mesh position={[-6, 2, 0]} rotation={[0, Math.PI / 2, 0]}>
<planeGeometry args={[8, 4]} /> <planeGeometry args={[8, 4]} />
<meshStandardMaterial color="#bae6fd" roughness={0.1} emissive="#bae6fd" emissiveIntensity={0.4} /> <meshStandardMaterial color="#bae6fd" roughness={0.1} emissive="#bae6fd" emissiveIntensity={0.4} />
@@ -323,7 +385,7 @@ function ThreeMeetingRoomScene({ currentActiveModel }: { currentActiveModel: any
<meshStandardMaterial color="#bae6fd" roughness={0.1} emissive="#bae6fd" emissiveIntensity={0.4} /> <meshStandardMaterial color="#bae6fd" roughness={0.1} emissive="#bae6fd" emissiveIntensity={0.4} />
</mesh> </mesh>
{/* Mesa Elíptica em U (Madeira Mel/Carvalho Clara e Acolhedora) */} {/* Mesa Elíptica em U (Madeira Mel Carvalho Clara) */}
<group position={[0, 0.8, 0]} rotation={[Math.PI / 2, 0, 0]}> <group position={[0, 0.8, 0]} rotation={[Math.PI / 2, 0, 0]}>
<mesh geometry={tableGeom}> <mesh geometry={tableGeom}>
<meshStandardMaterial color="#d97706" roughness={0.2} metalness={0.1} /> <meshStandardMaterial color="#d97706" roughness={0.2} metalness={0.1} />
@@ -346,31 +408,28 @@ function ThreeMeetingRoomScene({ currentActiveModel }: { currentActiveModel: any
<cylinderGeometry args={[0.26, 0.28, 0.05, 32]} /> <cylinderGeometry args={[0.26, 0.28, 0.05, 32]} />
<meshStandardMaterial color="#1e293b" metalness={0.8} roughness={0.2} /> <meshStandardMaterial color="#1e293b" metalness={0.8} roughness={0.2} />
</mesh> </mesh>
{/* Anel de luz azul brilhante */}
<mesh position={[0, 0.026, 0]} rotation={[-Math.PI / 2, 0, 0]}> <mesh position={[0, 0.026, 0]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0, 0.22, 32]} /> <ringGeometry args={[0, 0.22, 32]} />
<meshBasicMaterial color="#0ea5e9" transparent opacity={0.7} /> <meshBasicMaterial color="#0ea5e9" transparent opacity={0.7} />
</mesh> </mesh>
{/* Feixe de Luz Holográfico */}
<mesh position={[0, 0.45, 0]}> <mesh position={[0, 0.45, 0]}>
<cylinderGeometry args={[0.28, 0.2, 0.9, 32, 1, true]} /> <cylinderGeometry args={[0.28, 0.2, 0.9, 32, 1, true]} />
<meshBasicMaterial color="#0ea5e9" transparent opacity={0.15} side={THREE.DoubleSide} /> <meshBasicMaterial color="#0ea5e9" transparent opacity={0.15} side={THREE.DoubleSide} />
</mesh> </mesh>
{/* Modelo 3D da Maquete Holográfica */} {/* Modelo 3D da Maquete Holográfica */}
<group position={[0, 0.45, 0]} rotation={[0, Date.now() * 0.00012, 0]}> <group position={[0, 0.45, 0]}>
<Suspense fallback={ <Suspense fallback={
// Carregador holográfico temporário
<mesh> <mesh>
<sphereGeometry args={[0.1, 16, 16]} /> <sphereGeometry args={[0.1, 16, 16]} />
<meshBasicMaterial color="#00f5ff" wireframe /> <meshBasicMaterial color="#00f5ff" wireframe />
</mesh> </mesh>
}> }>
{currentActiveModel ? ( {currentActiveModel ? (
<HologramModel model={currentActiveModel} /> <HologramModel model={currentActiveModel} presentationState={presentationState} />
) : ( ) : (
// Holograma Padrão Geométrico se não houver maquete carregada // Holograma Padrão Geométrico se não houver maquete carregada
<group> <group rotation={[0, Date.now() * 0.00012, 0]}>
<mesh position={[0, 0, 0]}> <mesh position={[0, 0, 0]}>
<octahedronGeometry args={[0.22]} /> <octahedronGeometry args={[0.22]} />
<meshStandardMaterial color="#0ea5e9" transparent opacity={0.7} wireframe emissive="#0ea5e9" emissiveIntensity={0.5} /> <meshStandardMaterial color="#0ea5e9" transparent opacity={0.7} wireframe emissive="#0ea5e9" emissiveIntensity={0.5} />
@@ -411,7 +470,7 @@ function ThreeMeetingRoomScene({ currentActiveModel }: { currentActiveModel: any
</group> </group>
))} ))}
{/* Iluminação Super Clara e Acolhedora da Sala */} {/* Iluminação Clara da Sala */}
<hemisphereLight skyColor="#ffffff" groundColor="#64748b" intensity={1.4} /> <hemisphereLight skyColor="#ffffff" groundColor="#64748b" intensity={1.4} />
<directionalLight position={[6, 12, 6]} intensity={1.8} castShadow /> <directionalLight position={[6, 12, 6]} intensity={1.8} castShadow />
<directionalLight position={[-6, 8, -6]} intensity={0.5} /> <directionalLight position={[-6, 8, -6]} intensity={0.5} />
@@ -430,42 +489,74 @@ export default function MeetingRoom() {
// Estados do Lobby // Estados do Lobby
const [inRoom, setInRoom] = useState(false); const [inRoom, setInRoom] = useState(false);
const [name, setName] = useState(''); const [name, setName] = useState('');
const [cargo, setCargo] = useState('');
const [empresa, setEmpresa] = useState('');
const [isPresenter, setIsPresenter] = useState(!roomId); // Se não há roomId na URL, ele é o host
const [selectedAvatar, setSelectedAvatar] = useState(1); const [selectedAvatar, setSelectedAvatar] = useState(1);
const [selectedChair, setSelectedChair] = useState('1'); const [selectedChair, setSelectedChair] = useState('1');
const [inputRoomId, setInputRoomId] = useState(roomId || ''); const [inputRoomId, setInputRoomId] = useState(roomId || '');
// Estados da Sala // Estados da Sala
const [peers, setPeers] = useState<Record<string, { username: string; avatarId: number; chairId: string; presence_ref: string }>>({}); const [peers, setPeers] = useState<Record<string, { username: string; avatarId: number; chairId: string; role: string; cargo: string; empresa: string; presence_ref: string }>>({});
const [chatMessages, setChatMessages] = useState<{ id: string; sender: string; text: string; time: string }[]>([]); const [chatMessages, setChatMessages] = useState<{ id: string; sender: string; text: string; time: string }[]>([]);
const [newMessage, setNewMessage] = useState(''); const [newMessage, setNewMessage] = useState('');
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
const [isTalking, setIsTalking] = useState(false); const [isTalking, setIsTalking] = useState(false);
const [activeUsersCount, setActiveUsersCount] = useState(1); const [activeUsersCount, setActiveUsersCount] = useState(1);
const [roomCode, setRoomCode] = useState(roomId || ''); const [roomCode, setRoomCode] = useState(roomId || '');
const [activeTab, setActiveTab] = useState<'chat' | 'participants'>('participants');
// Estado compartilhado da Apresentação da maquete
const [presentationState, setPresentationState] = useState({
rotation: [0, 0, 0],
scale: 1,
sectionEnabled: false,
sectionAxis: 'y',
sectionLevel: 0.0,
sectionInvert: false
});
const channelRef = useRef<any>(null); const channelRef = useRef<any>(null);
const chatScrollRef = useRef<HTMLDivElement>(null); const chatScrollRef = useRef<HTMLDivElement>(null);
// Gerencia o loop de simulação de fala // Se o roomId mudar na url, atualiza o checkbox isPresenter
useEffect(() => {
setIsPresenter(!roomId);
setInputRoomId(roomId || '');
}, [roomId]);
// Simulação simples de detecção de áudio
useEffect(() => { useEffect(() => {
if (!inRoom || isMuted) { if (!inRoom || isMuted) {
setIsTalking(false); setIsTalking(false);
return; return;
} }
const interval = setInterval(() => { const interval = setInterval(() => {
// Simula detecção de microfone com probabilidade de atividade
setIsTalking(Math.random() > 0.7); setIsTalking(Math.random() > 0.7);
}, 1500); }, 1500);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [inRoom, isMuted]); }, [inRoom, isMuted]);
// Rola o chat para o fim quando houver mensagens
useEffect(() => { useEffect(() => {
if (chatScrollRef.current) { if (chatScrollRef.current) {
chatScrollRef.current.scrollTop = chatScrollRef.current.scrollHeight; chatScrollRef.current.scrollTop = chatScrollRef.current.scrollHeight;
} }
}, [chatMessages]); }, [chatMessages]);
// Envia as atualizações de apresentação via Broadcast para a sala inteira
const handlePresentationChange = (updatedFields: Partial<typeof presentationState>) => {
if (!isPresenter || !channelRef.current) return;
const newState = { ...presentationState, ...updatedFields };
setPresentationState(newState);
channelRef.current.send({
type: 'broadcast',
event: 'presentation_update',
payload: newState
});
};
// Inicializa o Supabase Presence & Broadcast para conexões // Inicializa o Supabase Presence & Broadcast para conexões
const connectToRoom = (targetRoomId: string) => { const connectToRoom = (targetRoomId: string) => {
if (!name.trim()) { if (!name.trim()) {
@@ -475,7 +566,7 @@ export default function MeetingRoom() {
const finalRoomId = targetRoomId.trim().toUpperCase() || Math.random().toString(36).substring(3, 8).toUpperCase(); const finalRoomId = targetRoomId.trim().toUpperCase() || Math.random().toString(36).substring(3, 8).toUpperCase();
setRoomCode(finalRoomId); setRoomCode(finalRoomId);
console.log(`🔌 [MeetingRoom] Conectando à sala: ${finalRoomId}`); console.log(`🔌 [MeetingRoom] Conectando à sala: ${finalRoomId} como ${isPresenter ? 'Apresentador' : 'Convidado'}`);
const channel = supabase.channel(`meeting_room_${finalRoomId}`, { const channel = supabase.channel(`meeting_room_${finalRoomId}`, {
config: { config: {
@@ -486,7 +577,7 @@ export default function MeetingRoom() {
channelRef.current = channel; channelRef.current = channel;
// Sincroniza participantes // Sincroniza participantes online
channel channel
.on('presence', { event: 'sync' }, () => { .on('presence', { event: 'sync' }, () => {
const state = channel.presenceState(); const state = channel.presenceState();
@@ -504,10 +595,10 @@ export default function MeetingRoom() {
setActiveUsersCount(count); setActiveUsersCount(count);
}) })
.on('presence', { event: 'join' }, ({ key, newPresences }) => { .on('presence', { event: 'join' }, ({ key, newPresences }) => {
toast.info(`Participante "${newPresences[0]?.username}" entrou no assento ${key}`); toast.info(`"${newPresences[0]?.username}" sentou-se no assento ${key}`);
}) })
.on('presence', { event: 'leave' }, ({ key, leftPresences }) => { .on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
toast.info(`Participante "${leftPresences[0]?.username}" desocupou o assento ${key}`); toast.info(`"${leftPresences[0]?.username}" liberou o assento ${key}`);
}); });
// Escuta mensagens do Chat via Broadcast // Escuta mensagens do Chat via Broadcast
@@ -523,21 +614,31 @@ export default function MeetingRoom() {
]); ]);
}); });
// Escuta atualizações de Apresentação da Maquete via Broadcast (apenas para convidados)
channel.on('broadcast', { event: 'presentation_update' }, (payload) => {
if (!isPresenter) {
setPresentationState(payload.payload);
}
});
// Inscrever no WebSocket // Inscrever no WebSocket
channel.subscribe(async (status) => { channel.subscribe(async (status) => {
if (status === 'SUBSCRIBED') { if (status === 'SUBSCRIBED') {
// Envia as informações do usuário atual para a presença // Envia as informações completas para o Presence do Supabase
await channel.track({ await channel.track({
username: name.substring(0, 10), username: name.substring(0, 10),
avatarId: selectedAvatar, avatarId: selectedAvatar,
chairId: selectedChair, chairId: selectedChair,
role: isPresenter ? 'presenter' : 'guest',
cargo: cargo.trim() || 'Colaborador',
empresa: empresa.trim() || 'SteelXR Corp',
joinedAt: new Date().toISOString(), joinedAt: new Date().toISOString(),
}); });
setInRoom(true); setInRoom(true);
toast.success(`Entrou na sala de reunião: ${finalRoomId}`); toast.success(`Entrou na sala de reunião: ${finalRoomId}`);
navigate(`/meeting/${finalRoomId}`, { replace: true }); navigate(`/meeting/${finalRoomId}`, { replace: true });
} else if (status === 'CHANNEL_ERROR') { } else if (status === 'CHANNEL_ERROR') {
toast.error('Erro de conexão ao canal de reuniões.'); toast.error('Erro ao conectar ao canal de reuniões.');
} }
}); });
}; };
@@ -559,7 +660,6 @@ export default function MeetingRoom() {
// Sair da Sala de Reunião // Sair da Sala de Reunião
const handleLeaveRoom = async () => { const handleLeaveRoom = async () => {
if (channelRef.current) { if (channelRef.current) {
console.log('🔌 [MeetingRoom] Saindo da sala...');
await supabase.removeChannel(channelRef.current); await supabase.removeChannel(channelRef.current);
} }
setInRoom(false); setInRoom(false);
@@ -568,12 +668,10 @@ export default function MeetingRoom() {
navigate('/'); navigate('/');
}; };
// Identifica quais cadeiras já estão ocupadas por outros participantes
const occupiedChairs = useMemo(() => { const occupiedChairs = useMemo(() => {
return Object.keys(peers); return Object.keys(peers);
}, [peers]); }, [peers]);
// Escolha do Preset de Avatar no Lobby (Navegação)
const nextAvatar = () => { const nextAvatar = () => {
setSelectedAvatar((prev) => (prev === 10 ? 1 : prev + 1)); setSelectedAvatar((prev) => (prev === 10 ? 1 : prev + 1));
}; };
@@ -595,22 +693,21 @@ export default function MeetingRoom() {
</div> </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="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"> <div className="mb-6 text-center">
<h2 className="text-2xl font-bold tracking-tight text-foreground md:text-3xl"> <h2 className="text-2xl font-bold tracking-tight text-foreground md:text-3xl">
Lobby de <span className="text-primary">Reunião Virtual</span> Lobby de <span className="text-primary">Reunião Virtual</span>
</h2> </h2>
<p className="mt-1.5 text-xs font-mono uppercase tracking-wider text-muted-foreground"> <p className="mt-1 text-xs font-mono uppercase tracking-wider text-muted-foreground">
Escolha seu visual Mii e selecione seu assento Configure suas credenciais e escolha seu visual Mii
</p> </p>
</div> </div>
<div className="grid grid-cols-1 gap-8 md:grid-cols-12"> <div className="grid grid-cols-1 gap-6 md:grid-cols-12">
{/* Seletor de Avatar (Lado Esquerdo - 5 colunas) */} {/* Avatar Mii Selector (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"> <div className="flex flex-col items-center justify-center rounded-xl border bg-muted/30 p-5 md:col-span-5 relative">
<span className="absolute top-3 left-4 font-mono text-[9px] uppercase tracking-widest text-muted-foreground">Modelo de Avatar</span> <span className="absolute top-3 left-4 font-mono text-[9px] uppercase tracking-widest text-muted-foreground">Visual Mii</span>
{/* Modelo Virtual do Mii no Centro */} <div className="h-40 w-full flex items-center justify-center relative bg-slate-900/60 rounded-lg overflow-hidden border border-border">
<div className="h-44 w-full flex items-center justify-center relative bg-slate-900/60 rounded-lg overflow-hidden border border-border">
<Canvas camera={{ position: [0, 0.16, 0.28], fov: 40 }}> <Canvas camera={{ position: [0, 0.16, 0.28], fov: 40 }}>
<ambientLight intensity={0.8} /> <ambientLight intensity={0.8} />
<directionalLight position={[2, 3, 2]} intensity={1.5} /> <directionalLight position={[2, 3, 2]} intensity={1.5} />
@@ -618,7 +715,6 @@ export default function MeetingRoom() {
<MiiAvatar avatarId={selectedAvatar} username={name || 'Mii'} position={[0, 0, 0]} isTalking={true} /> <MiiAvatar avatarId={selectedAvatar} username={name || 'Mii'} position={[0, 0, 0]} isTalking={true} />
</Canvas> </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}> <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" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
@@ -627,33 +723,56 @@ export default function MeetingRoom() {
</Button> </Button>
</div> </div>
{/* Informações do avatar */} <div className="mt-3 text-center">
<div className="mt-4 text-center">
<span className="font-mono text-xs font-bold uppercase tracking-wider text-foreground"> <span className="font-mono text-xs font-bold uppercase tracking-wider text-foreground">
{currentAvatar.name} {currentAvatar.name}
</span> </span>
<p className="text-[10px] text-muted-foreground mt-0.5">{currentAvatar.desc}</p> <p className="text-[9px] text-muted-foreground mt-0.5">{currentAvatar.desc}</p>
</div> </div>
{/* Nome */} {/* Nome */}
<div className="mt-6 w-full space-y-2"> <div className="mt-4 w-full space-y-2">
<label className="font-mono text-[10px] uppercase text-muted-foreground">Seu Nome (Máx 10 letras)</label> <label className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground">Seu Nome (Máx 10 letras)</label>
<Input <Input
type="text" type="text"
maxLength={10} maxLength={10}
placeholder="Ex: Pedro" placeholder="Nome do usuário"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="font-semibold text-center uppercase font-mono tracking-wider" className="font-semibold text-center uppercase font-mono tracking-wider text-xs h-9"
/>
</div>
</div>
{/* Credenciais, Assento e Conexão (Lado Direito - 7 colunas) */}
<div className="flex flex-col justify-between md:col-span-7 space-y-5">
{/* Form de Cargo e Empresa */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground">Cargo / Função</label>
<Input
type="text"
placeholder="Ex: Eng. Civil"
value={cargo}
onChange={(e) => setCargo(e.target.value)}
className="text-xs h-9"
/>
</div>
<div className="space-y-1.5">
<label className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground">Empresa</label>
<Input
type="text"
placeholder="Ex: Steel Co."
value={empresa}
onChange={(e) => setEmpresa(e.target.value)}
className="text-xs h-9"
/> />
</div> </div>
</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 */} {/* Seleção do Assento */}
<div className="space-y-3"> <div className="space-y-2">
<span className="font-mono text-[10px] uppercase text-muted-foreground">Selecione seu Assento na Mesa</span> <span className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground">Selecione seu Assento na Mesa</span>
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-4 gap-2">
{CHAIRS.map((chair) => { {CHAIRS.map((chair) => {
const isOccupied = occupiedChairs.includes(chair.id); const isOccupied = occupiedChairs.includes(chair.id);
@@ -662,15 +781,15 @@ export default function MeetingRoom() {
<Button <Button
key={chair.id} key={chair.id}
variant={isSelected ? 'default' : 'outline'} variant={isSelected ? 'default' : 'outline'}
className={`h-11 flex-col p-1 gap-0.5 text-[10px] font-mono ${ className={`h-11 flex-col p-1 gap-0.5 text-[9px] font-mono ${
isOccupied ? 'border-destructive/30 text-destructive bg-destructive/5 cursor-not-allowed hover:bg-destructive/5 hover:text-destructive' : '' isOccupied ? 'border-destructive/30 text-destructive bg-destructive/5 cursor-not-allowed hover:bg-destructive/5' : ''
}`} }`}
disabled={isOccupied} disabled={isOccupied}
onClick={() => setSelectedChair(chair.id)} onClick={() => setSelectedChair(chair.id)}
> >
<span className="font-semibold">{chair.name}</span> <span className="font-semibold">{chair.name}</span>
<span className="text-[8px] opacity-75"> <span className="text-[8px] opacity-75">
{isOccupied ? 'Ocupado' : isSelected ? 'Selecionado' : 'Livre'} {isOccupied ? 'Ocupado' : isSelected ? 'Escolhido' : 'Livre'}
</span> </span>
</Button> </Button>
); );
@@ -678,43 +797,42 @@ export default function MeetingRoom() {
</div> </div>
</div> </div>
{/* ID da Sala / Ação */} {/* Papel e Conexão */}
<div className="rounded-xl border bg-muted/20 p-4 space-y-4"> <div className="rounded-xl border bg-muted/20 p-4 space-y-4">
<div className="space-y-2"> <div className="flex items-center space-x-2">
<label className="font-mono text-[10px] uppercase text-muted-foreground">Código da Sala (Deixe em branco para criar nova)</label> <Checkbox
<div className="flex gap-2"> id="presenter-mode"
checked={isPresenter}
onCheckedChange={(checked) => setIsPresenter(!!checked)}
/>
<label
htmlFor="presenter-mode"
className="text-xs font-mono font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-foreground cursor-pointer"
>
Entrar como Apresentador (Host) da Sala
</label>
</div>
<div className="space-y-1.5">
<label className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground">Código da Sala (Vazio para criar nova)</label>
<Input <Input
type="text" type="text"
placeholder="Ex: TS-X9F" placeholder="Ex: TS-X9F"
value={inputRoomId} value={inputRoomId}
onChange={(e) => setInputRoomId(e.target.value.toUpperCase())} onChange={(e) => setInputRoomId(e.target.value.toUpperCase())}
className="font-mono tracking-widest font-bold text-center" className="font-mono tracking-widest font-bold text-center text-xs h-9"
/> />
</div> </div>
</div>
<div className="flex gap-3 pt-2"> <div className="flex gap-2.5 pt-1">
<Button variant="outline" className="flex-1 font-mono text-xs gap-1.5 h-12" onClick={() => navigate('/')}> <Button variant="outline" className="flex-1 font-mono text-xs h-10" onClick={() => navigate('/')}>
Cancelar Cancelar
</Button> </Button>
<Button className="flex-1 glow-primary font-mono text-xs gap-2 h-12" onClick={() => connectToRoom(inputRoomId)}> <Button className="flex-1 glow-primary font-mono text-xs h-10 gap-2" onClick={() => connectToRoom(inputRoomId)}>
Entrar na Reunião <ArrowRight className="h-4 w-4" /> Acessar Reunião <ArrowRight className="h-4 w-4" />
</Button> </Button>
</div> </div>
</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>
</div> </div>
@@ -725,28 +843,27 @@ export default function MeetingRoom() {
// --- Renderização da Sala de Reunião Ativa --- // --- Renderização da Sala de Reunião Ativa ---
return ( return (
<div className="flex h-screen overflow-hidden bg-slate-950 text-white relative animate-in fade-in duration-300"> <div className="flex h-screen overflow-hidden bg-slate-950 text-white relative animate-in fade-in duration-350">
{/* 3D Canvas - Sala Virtual Clara */} {/* 3D Canvas - Sala Clara */}
<div className="flex-1 h-full relative"> <div className="flex-1 h-full relative">
<Canvas camera={{ position: [0, 2.5, 2.8], fof: 45 }} shadows> <Canvas camera={{ position: [0, 2.5, 2.8], fov: 45 }} shadows gl={{ localClippingEnabled: true }}>
<ThreeMeetingRoomScene currentActiveModel={activeModel} /> <ThreeMeetingRoomScene currentActiveModel={activeModel} presentationState={presentationState} />
{/* Renderizar avatares dos outros participantes na sala */} {/* Renderizar avatares de TODOS na sala (incluindo o próprio usuário para ele se ver sentado na cadeira) */}
{Object.keys(peers).map((chairKey) => { {Object.keys(peers).map((chairKey) => {
const peer = peers[chairKey]; const peer = peers[chairKey];
const chairConf = CHAIRS.find((c) => c.id === peer.chairId); const chairConf = CHAIRS.find((c) => c.id === peer.chairId);
if (!chairConf) return null; if (!chairConf) return null;
// Não renderiza a nós mesmos na cena como avatar, nossa câmera já está na cadeira! const isLocalUser = peer.chairId === selectedChair;
if (peer.chairId === selectedChair) return null;
return ( return (
<MiiAvatar <MiiAvatar
key={peer.presence_ref} key={peer.presence_ref}
avatarId={peer.avatarId} avatarId={peer.avatarId}
username={peer.username} username={isLocalUser ? `${peer.username} (Você)` : peer.username}
position={chairConf.pos} position={chairConf.pos}
isTalking={isTalking && Math.random() > 0.5} // Simulação de fala isTalking={isLocalUser ? isTalking : (isTalking && Math.random() > 0.5)}
/> />
); );
})} })}
@@ -757,19 +874,146 @@ export default function MeetingRoom() {
dampingFactor={0.05} dampingFactor={0.05}
minDistance={0.5} minDistance={0.5}
maxDistance={8} maxDistance={8}
maxPolarAngle={Math.PI / 2 - 0.05} // Não permite atravessar o chão maxPolarAngle={Math.PI / 2 - 0.05}
target={[0, 0.95, 0]} // Foca no centro da mesa target={[0, 0.95, 0]}
/> />
</Canvas> </Canvas>
{/* HUD Superior / Detalhes da Conexão */} {/* Painel do Apresentador (Controles de Manipulação Sincronizados) */}
{isPresenter && (
<div className="absolute left-4 bottom-16 w-80 rounded-xl border border-primary/30 bg-slate-900/95 p-4 shadow-2xl backdrop-blur z-20 space-y-4 pointer-events-auto">
<div className="flex items-center justify-between border-b border-slate-800 pb-2">
<span className="font-mono text-xs font-bold text-primary flex items-center gap-1.5">
<Award className="h-4 w-4 animate-pulse" /> Painel do Apresentador
</span>
<span className="font-mono text-[8px] bg-emerald-500/25 text-emerald-400 px-1.5 py-0.5 rounded uppercase">Sincronizado</span>
</div>
{/* Rotação Y */}
<div className="space-y-1">
<div className="flex justify-between text-[10px] font-mono text-muted-foreground">
<span>Giro da Peça (Horizontal)</span>
<span>{Math.round((presentationState.rotation[1] * 180) / Math.PI)}°</span>
</div>
<input
type="range"
min={-Math.PI}
max={Math.PI}
step={0.05}
value={presentationState.rotation[1]}
onChange={(e) => handlePresentationChange({ rotation: [0, parseFloat(e.target.value), 0] })}
className="w-full h-1 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
{/* Escala */}
<div className="space-y-1">
<div className="flex justify-between text-[10px] font-mono text-muted-foreground">
<span>Escala (Zoom da Peça)</span>
<span>{presentationState.scale.toFixed(1)}x</span>
</div>
<input
type="range"
min={0.3}
max={3.0}
step={0.1}
value={presentationState.scale}
onChange={(e) => handlePresentationChange({ scale: parseFloat(e.target.value) })}
className="w-full h-1 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
{/* Plano de Corte */}
<div className="space-y-2 pt-1 border-t border-slate-800">
<div className="flex items-center justify-between">
<span className="font-mono text-[10px] text-muted-foreground">Corte de Seção (Clipping)</span>
<input
type="checkbox"
id="section-cuts-enable"
checked={presentationState.sectionEnabled}
onChange={(e) => handlePresentationChange({ sectionEnabled: e.target.checked })}
className="rounded border-slate-800 bg-slate-950 accent-primary cursor-pointer h-3.5 w-3.5"
/>
</div>
{presentationState.sectionEnabled && (
<div className="space-y-3 animate-in fade-in slide-in-from-bottom-2 duration-150">
{/* Seletor Eixo */}
<div className="flex gap-2">
{['x', 'y', 'z'].map((ax) => (
<Button
key={ax}
size="sm"
variant={presentationState.sectionAxis === ax ? 'default' : 'outline'}
className="flex-1 h-7 text-[9px] font-mono uppercase"
onClick={() => handlePresentationChange({ sectionAxis: ax })}
>
Eixo {ax}
</Button>
))}
</div>
{/* Slider de Nível */}
<div className="space-y-1">
<div className="flex justify-between text-[9px] font-mono text-muted-foreground">
<span>Posição do Corte</span>
<span>{(presentationState.sectionLevel * 100).toFixed(0)}%</span>
</div>
<input
type="range"
min={-0.5}
max={0.5}
step={0.01}
value={presentationState.sectionLevel}
onChange={(e) => handlePresentationChange({ sectionLevel: parseFloat(e.target.value) })}
className="w-full h-1 bg-slate-800 rounded-lg appearance-none cursor-pointer accent-primary"
/>
</div>
{/* Inverter Corte */}
<div className="flex items-center justify-between">
<label htmlFor="invert-section" className="text-[9px] font-mono text-muted-foreground cursor-pointer">
Inverter Direção do Corte
</label>
<input
type="checkbox"
id="invert-section"
checked={presentationState.sectionInvert}
onChange={(e) => handlePresentationChange({ sectionInvert: e.target.checked })}
className="rounded border-slate-800 bg-slate-950 accent-primary cursor-pointer h-3 w-3"
/>
</div>
</div>
)}
</div>
{/* Botão de Reset */}
<Button
variant="outline"
size="sm"
className="w-full h-8 font-mono text-[9px] gap-1.5"
onClick={() => handlePresentationChange({
rotation: [0, 0, 0],
scale: 1,
sectionEnabled: false,
sectionAxis: 'y',
sectionLevel: 0.0,
sectionInvert: false
})}
>
<RotateCw className="h-3 w-3" /> Resetar Apresentação
</Button>
</div>
)}
{/* HUD Superior */}
<div className="absolute top-4 left-4 right-4 pointer-events-none flex justify-between items-start z-10"> <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"> <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" /> <Users className="h-4 w-4 text-primary" />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-mono text-[9px] text-muted-foreground uppercase">Reunião Conectada</span> <span className="font-mono text-[9px] text-muted-foreground uppercase">Reunião Conectada</span>
<span className="font-mono text-xs font-semibold text-foreground"> <span className="font-mono text-xs font-semibold text-foreground">
Membros Online: {activeUsersCount}/8 Online: {activeUsersCount}/8
</span> </span>
</div> </div>
</div> </div>
@@ -785,7 +1029,7 @@ export default function MeetingRoom() {
className="h-5 w-5 text-muted-foreground hover:text-foreground" className="h-5 w-5 text-muted-foreground hover:text-foreground"
onClick={() => { onClick={() => {
navigator.clipboard.writeText(window.location.href); navigator.clipboard.writeText(window.location.href);
toast.success('Link do convite copiado!'); toast.success('Link de convite copiado!');
}} }}
title="Copiar link do convite" title="Copiar link do convite"
> >
@@ -800,7 +1044,7 @@ export default function MeetingRoom() {
</div> </div>
</div> </div>
{/* HUD Inferior - Controles de Áudio e Instrução */} {/* HUD Inferior - Controles de Áudio */}
<div className="absolute bottom-4 left-4 pointer-events-none z-10 flex gap-2"> <div className="absolute bottom-4 left-4 pointer-events-none z-10 flex gap-2">
<Button <Button
variant={isMuted ? 'destructive' : 'default'} variant={isMuted ? 'destructive' : 'default'}
@@ -813,26 +1057,97 @@ export default function MeetingRoom() {
</Button> </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"> <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 de Visão</span> <span className="font-mono text-[8px] text-muted-foreground uppercase">
{isPresenter ? 'Apresentação Ativa' : 'Observador'}
</span>
<span className="text-[10px] text-foreground font-mono"> <span className="text-[10px] text-foreground font-mono">
Clique e arraste com o botão esquerdo para girar a mesa e olhar os avatares {isPresenter
? 'Você está transmitindo os cortes e rotações da maquete.'
: 'Aguarde as rotações e planos de corte efetuados pelo Host.'}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
{/* Painel Lateral Direito - Chat e Lista de Usuários (Largura 320px) */} {/* Painel Lateral Direito (Abas Chat / Participantes) */}
<div className="w-80 border-l border-slate-800 bg-slate-900/95 flex flex-col h-full z-10 shadow-2xl"> <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 */} {/* Seletor de Abas */}
<div className="border-b border-slate-800 p-4 flex items-center justify-between"> <div className="flex border-b border-slate-800 bg-slate-950/40">
<div className="flex items-center gap-2"> <Button
<MessageSquare className="h-4.5 w-4.5 text-primary" /> variant="ghost"
<span className="font-mono text-xs font-bold uppercase tracking-wider">Chat da Reunião</span> className={`flex-1 rounded-none border-b-2 font-mono text-[10px] uppercase h-12 gap-1.5 ${
</div> activeTab === 'participants' ? 'border-primary text-primary bg-slate-900/40' : 'border-transparent text-muted-foreground'
<span className="font-mono text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">Tempo Real</span> }`}
onClick={() => setActiveTab('participants')}
>
<Users className="h-3.5 w-3.5" /> Membros ({activeUsersCount})
</Button>
<Button
variant="ghost"
className={`flex-1 rounded-none border-b-2 font-mono text-[10px] uppercase h-12 gap-1.5 ${
activeTab === 'chat' ? 'border-primary text-primary bg-slate-900/40' : 'border-transparent text-muted-foreground'
}`}
onClick={() => setActiveTab('chat')}
>
<MessageSquare className="h-3.5 w-3.5" /> Chat
</Button>
</div> </div>
{/* Histórico do Chat */} {/* ABA PARTICIPANTES */}
{activeTab === 'participants' && (
<div className="flex-1 overflow-y-auto p-4 space-y-3 scrollbar-thin">
<span className="font-mono text-[9px] text-muted-foreground uppercase tracking-widest block mb-2">Quem está na sala</span>
<div className="space-y-2.5">
{Object.keys(peers).map((chairKey) => {
const peer = peers[chairKey];
const isHost = peer.role === 'presenter';
const avatar = AVATAR_PRESETS[peer.avatarId - 1] || AVATAR_PRESETS[0];
const isMe = peer.chairId === selectedChair;
return (
<div key={peer.presence_ref} className="rounded-lg border border-slate-800 bg-slate-950/40 p-3 flex items-start gap-3 relative overflow-hidden">
{/* Indicador da cor do avatar do Mii */}
<div className="h-8 w-8 rounded-full border border-slate-800 flex items-center justify-center font-bold text-xs" style={{ backgroundColor: `${avatar.color}35`, color: avatar.color }}>
{peer.username.substring(0, 2).toUpperCase()}
</div>
{/* Detalhes do Usuário */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-mono text-xs font-bold text-slate-100 truncate block">
{peer.username} {isMe && '(Você)'}
</span>
{isHost && (
<span className="text-[8px] font-mono bg-primary/20 text-primary px-1.5 py-0.5 rounded uppercase font-semibold">
Host
</span>
)}
</div>
{/* Cargo e Empresa */}
<span className="font-mono text-[9.5px] text-muted-foreground block truncate mt-0.5 uppercase">
💼 {peer.cargo}
</span>
<span className="font-mono text-[9px] text-primary/75 block truncate uppercase">
🏢 {peer.empresa}
</span>
</div>
<div className="absolute top-2 right-2.5 font-mono text-[8px] text-slate-600">
Chair {peer.chairId}
</div>
</div>
);
})}
</div>
</div>
)}
{/* ABA CHAT */}
{activeTab === 'chat' && (
<>
<div ref={chatScrollRef} className="flex-1 overflow-y-auto p-4 space-y-3 scrollbar-thin"> <div ref={chatScrollRef} className="flex-1 overflow-y-auto p-4 space-y-3 scrollbar-thin">
{chatMessages.length === 0 ? ( {chatMessages.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-slate-500 text-center px-4 space-y-2"> <div className="h-full flex flex-col items-center justify-center text-slate-500 text-center px-4 space-y-2">
@@ -872,6 +1187,8 @@ export default function MeetingRoom() {
<Send className="h-3.5 w-3.5" /> <Send className="h-3.5 w-3.5" />
</Button> </Button>
</div> </div>
</>
)}
</div> </div>
</div> </div>
); );