🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 00:19:23
This commit is contained in:
+446
-129
@@ -1,34 +1,38 @@
|
||||
import { useEffect, useRef, useState, useMemo, Suspense } from 'react';
|
||||
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 * 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 { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Users, MessageSquare, Mic, MicOff, Home, Send,
|
||||
ArrowRight, ChevronLeft, ChevronRight, Share2, LogOut
|
||||
Users, MessageSquare, Mic, MicOff, Home, Send, Slider,
|
||||
ArrowRight, ChevronLeft, ChevronRight, Share2, LogOut, Check,
|
||||
Scissors, RotateCw, Maximize2, ShieldAlert, Award
|
||||
} from 'lucide-react';
|
||||
|
||||
// --- 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 = [
|
||||
{ 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] },
|
||||
{ id: '1', name: 'Assento 1', pos: [-1.2, 0.95, -1.45], 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.4, 0.95, -1.45], 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, 1.45], 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.4, 0.95, 1.45], 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 = [
|
||||
{ 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: 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' },
|
||||
@@ -40,7 +44,7 @@ const AVATAR_PRESETS = [
|
||||
{ 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'];
|
||||
|
||||
// --- 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) {
|
||||
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 skin = SKIN_COLORS[(avatarId - 1) % SKIN_COLORS.length];
|
||||
return {
|
||||
@@ -68,14 +72,13 @@ function MiiAvatar({ avatarId, username, isTalking, ...props }: { avatarId: numb
|
||||
|
||||
useFrame((state) => {
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<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]}>
|
||||
<mesh position={[0, 0, -0.001]}>
|
||||
<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} />
|
||||
</mesh>
|
||||
|
||||
{/* BOCA (Sorriso) */}
|
||||
{/* BOCA */}
|
||||
<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]} />
|
||||
<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)
|
||||
function HologramModel({ model }: { model: { url: string } }) {
|
||||
// Carregador e Escalador Inteligente da Maquete Holográfica
|
||||
// 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 clone = useMemo(() => scene.clone(), [scene]);
|
||||
const groupRef = useRef<THREE.Group>(null);
|
||||
|
||||
// Calcula escala de aproximadamente 1 metro para caber na mesa
|
||||
useEffect(() => {
|
||||
if (!groupRef.current) return;
|
||||
|
||||
// Medir limites tridimensionais da maquete importada
|
||||
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) {
|
||||
// Ajusta a escala de forma inteligente para ter aproximadamente 1 metro (ex: 0.95m)
|
||||
const targetSize = 0.95;
|
||||
const scaleFactor = targetSize / maxDim;
|
||||
|
||||
groupRef.current.scale.setScalar(scaleFactor);
|
||||
|
||||
// Centralizar o pivot em (0,0,0)
|
||||
const center = new THREE.Vector3();
|
||||
box.getCenter(center);
|
||||
|
||||
// Centraliza e levanta ligeiramente acima da mesa
|
||||
clone.position.copy(center).multiplyScalar(-scaleFactor);
|
||||
clone.position.y += 0.02;
|
||||
}
|
||||
}, [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 (
|
||||
<group ref={groupRef}>
|
||||
<primitive object={clone} />
|
||||
@@ -271,8 +334,8 @@ function HologramModel({ model }: { model: { url: string } }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Cenário Claro, Limpo e Iluminado da Sala de Reunião
|
||||
function ThreeMeetingRoomScene({ currentActiveModel }: { currentActiveModel: any }) {
|
||||
// Cenário da Sala de Reunião Clara
|
||||
function ThreeMeetingRoomScene({ currentActiveModel, presentationState }: { currentActiveModel: any; presentationState: any }) {
|
||||
const tableGeom = useMemo(() => {
|
||||
// Geometria da mesa em U
|
||||
const shape = new THREE.Shape();
|
||||
@@ -286,7 +349,6 @@ function ThreeMeetingRoomScene({ currentActiveModel }: { currentActiveModel: any
|
||||
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);
|
||||
@@ -296,7 +358,7 @@ function ThreeMeetingRoomScene({ currentActiveModel }: { currentActiveModel: any
|
||||
|
||||
return (
|
||||
<group>
|
||||
{/* Piso de Laca Clara (Sala muito mais clara e iluminada!) */}
|
||||
{/* Piso Clara */}
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]}>
|
||||
<planeGeometry args={[12, 8]} />
|
||||
<meshStandardMaterial color="#e2e8f0" roughness={0.4} metalness={0.1} />
|
||||
@@ -313,7 +375,7 @@ function ThreeMeetingRoomScene({ currentActiveModel }: { currentActiveModel: any
|
||||
<planeGeometry args={[12, 4]} />
|
||||
<meshStandardMaterial color="#f1f5f9" roughness={0.9} />
|
||||
</mesh>
|
||||
{/* Janelas Panorâmicas Ensolaradas */}
|
||||
{/* Janelas Panorâmicas */}
|
||||
<mesh position={[-6, 2, 0]} rotation={[0, Math.PI / 2, 0]}>
|
||||
<planeGeometry args={[8, 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} />
|
||||
</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]}>
|
||||
<mesh geometry={tableGeom}>
|
||||
<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]} />
|
||||
<meshStandardMaterial color="#1e293b" metalness={0.8} roughness={0.2} />
|
||||
</mesh>
|
||||
{/* Anel de luz azul 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.7} />
|
||||
</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.15} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
|
||||
{/* 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={
|
||||
// Carregador holográfico temporário
|
||||
<mesh>
|
||||
<sphereGeometry args={[0.1, 16, 16]} />
|
||||
<meshBasicMaterial color="#00f5ff" wireframe />
|
||||
</mesh>
|
||||
}>
|
||||
{currentActiveModel ? (
|
||||
<HologramModel model={currentActiveModel} />
|
||||
<HologramModel model={currentActiveModel} presentationState={presentationState} />
|
||||
) : (
|
||||
// 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]}>
|
||||
<octahedronGeometry args={[0.22]} />
|
||||
<meshStandardMaterial color="#0ea5e9" transparent opacity={0.7} wireframe emissive="#0ea5e9" emissiveIntensity={0.5} />
|
||||
@@ -411,7 +470,7 @@ function ThreeMeetingRoomScene({ currentActiveModel }: { currentActiveModel: any
|
||||
</group>
|
||||
))}
|
||||
|
||||
{/* Iluminação Super Clara e Acolhedora da Sala */}
|
||||
{/* Iluminação Clara da Sala */}
|
||||
<hemisphereLight skyColor="#ffffff" groundColor="#64748b" intensity={1.4} />
|
||||
<directionalLight position={[6, 12, 6]} intensity={1.8} castShadow />
|
||||
<directionalLight position={[-6, 8, -6]} intensity={0.5} />
|
||||
@@ -430,42 +489,74 @@ export default function MeetingRoom() {
|
||||
// Estados do Lobby
|
||||
const [inRoom, setInRoom] = useState(false);
|
||||
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 [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 [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 [newMessage, setNewMessage] = useState('');
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isTalking, setIsTalking] = useState(false);
|
||||
const [activeUsersCount, setActiveUsersCount] = useState(1);
|
||||
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 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(() => {
|
||||
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]);
|
||||
|
||||
// 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
|
||||
const connectToRoom = (targetRoomId: string) => {
|
||||
if (!name.trim()) {
|
||||
@@ -475,7 +566,7 @@ export default function MeetingRoom() {
|
||||
const finalRoomId = targetRoomId.trim().toUpperCase() || Math.random().toString(36).substring(3, 8).toUpperCase();
|
||||
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}`, {
|
||||
config: {
|
||||
@@ -486,7 +577,7 @@ export default function MeetingRoom() {
|
||||
|
||||
channelRef.current = channel;
|
||||
|
||||
// Sincroniza participantes
|
||||
// Sincroniza participantes online
|
||||
channel
|
||||
.on('presence', { event: 'sync' }, () => {
|
||||
const state = channel.presenceState();
|
||||
@@ -504,10 +595,10 @@ export default function MeetingRoom() {
|
||||
setActiveUsersCount(count);
|
||||
})
|
||||
.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 }) => {
|
||||
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
|
||||
@@ -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
|
||||
channel.subscribe(async (status) => {
|
||||
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({
|
||||
username: name.substring(0, 10),
|
||||
avatarId: selectedAvatar,
|
||||
chairId: selectedChair,
|
||||
role: isPresenter ? 'presenter' : 'guest',
|
||||
cargo: cargo.trim() || 'Colaborador',
|
||||
empresa: empresa.trim() || 'SteelXR Corp',
|
||||
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.');
|
||||
toast.error('Erro ao conectar ao canal de reuniões.');
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -559,7 +660,6 @@ export default function MeetingRoom() {
|
||||
// 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);
|
||||
@@ -568,12 +668,10 @@ export default function MeetingRoom() {
|
||||
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));
|
||||
};
|
||||
@@ -595,22 +693,21 @@ export default function MeetingRoom() {
|
||||
</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">
|
||||
<div className="mb-6 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">
|
||||
Escolha seu visual Mii e selecione seu assento
|
||||
<p className="mt-1 text-xs font-mono uppercase tracking-wider text-muted-foreground">
|
||||
Configure suas credenciais e escolha seu visual Mii
|
||||
</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">Modelo de Avatar</span>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-12">
|
||||
{/* Avatar Mii Selector (Lado Esquerdo - 5 colunas) */}
|
||||
<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">Visual Mii</span>
|
||||
|
||||
{/* Modelo Virtual do Mii no Centro */}
|
||||
<div className="h-44 w-full flex items-center justify-center relative bg-slate-900/60 rounded-lg overflow-hidden border border-border">
|
||||
<div className="h-40 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 }}>
|
||||
<ambientLight intensity={0.8} />
|
||||
<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} />
|
||||
</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>
|
||||
@@ -627,33 +723,56 @@ export default function MeetingRoom() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Informações do avatar */}
|
||||
<div className="mt-4 text-center">
|
||||
<div className="mt-3 text-center">
|
||||
<span className="font-mono text-xs font-bold uppercase tracking-wider text-foreground">
|
||||
{currentAvatar.name}
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
<div className="mt-4 w-full space-y-2">
|
||||
<label className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground">Seu Nome (Máx 10 letras)</label>
|
||||
<Input
|
||||
type="text"
|
||||
maxLength={10}
|
||||
placeholder="Ex: Pedro"
|
||||
placeholder="Nome do usuário"
|
||||
value={name}
|
||||
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>
|
||||
|
||||
{/* 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="space-y-2">
|
||||
<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">
|
||||
{CHAIRS.map((chair) => {
|
||||
const isOccupied = occupiedChairs.includes(chair.id);
|
||||
@@ -662,15 +781,15 @@ export default function MeetingRoom() {
|
||||
<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' : ''
|
||||
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' : ''
|
||||
}`}
|
||||
disabled={isOccupied}
|
||||
onClick={() => setSelectedChair(chair.id)}
|
||||
>
|
||||
<span className="font-semibold">{chair.name}</span>
|
||||
<span className="text-[8px] opacity-75">
|
||||
{isOccupied ? 'Ocupado' : isSelected ? 'Selecionado' : 'Livre'}
|
||||
{isOccupied ? 'Ocupado' : isSelected ? 'Escolhido' : 'Livre'}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
@@ -678,43 +797,42 @@ export default function MeetingRoom() {
|
||||
</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="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">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
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
|
||||
type="text"
|
||||
placeholder="Ex: TS-X9F"
|
||||
value={inputRoomId}
|
||||
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 className="flex gap-3 pt-2">
|
||||
<Button variant="outline" className="flex-1 font-mono text-xs gap-1.5 h-12" onClick={() => navigate('/')}>
|
||||
<div className="flex gap-2.5 pt-1">
|
||||
<Button variant="outline" className="flex-1 font-mono text-xs h-10" 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 className="flex-1 glow-primary font-mono text-xs h-10 gap-2" onClick={() => connectToRoom(inputRoomId)}>
|
||||
Acessar 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>
|
||||
@@ -725,28 +843,27 @@ export default function MeetingRoom() {
|
||||
// --- Renderização da Sala de Reunião Ativa ---
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-slate-950 text-white relative animate-in fade-in duration-300">
|
||||
{/* 3D Canvas - Sala Virtual Clara */}
|
||||
<div className="flex h-screen overflow-hidden bg-slate-950 text-white relative animate-in fade-in duration-350">
|
||||
{/* 3D Canvas - Sala Clara */}
|
||||
<div className="flex-1 h-full relative">
|
||||
<Canvas camera={{ position: [0, 2.5, 2.8], fof: 45 }} shadows>
|
||||
<ThreeMeetingRoomScene currentActiveModel={activeModel} />
|
||||
<Canvas camera={{ position: [0, 2.5, 2.8], fov: 45 }} shadows gl={{ localClippingEnabled: true }}>
|
||||
<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) => {
|
||||
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 isLocalUser = peer.chairId === selectedChair;
|
||||
|
||||
return (
|
||||
<MiiAvatar
|
||||
key={peer.presence_ref}
|
||||
avatarId={peer.avatarId}
|
||||
username={peer.username}
|
||||
username={isLocalUser ? `${peer.username} (Você)` : peer.username}
|
||||
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}
|
||||
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
|
||||
maxPolarAngle={Math.PI / 2 - 0.05}
|
||||
target={[0, 0.95, 0]}
|
||||
/>
|
||||
</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="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">
|
||||
Membros Online: {activeUsersCount}/8
|
||||
Online: {activeUsersCount}/8
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -785,7 +1029,7 @@ export default function MeetingRoom() {
|
||||
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(window.location.href);
|
||||
toast.success('Link do convite copiado!');
|
||||
toast.success('Link de convite copiado!');
|
||||
}}
|
||||
title="Copiar link do convite"
|
||||
>
|
||||
@@ -800,7 +1044,7 @@ export default function MeetingRoom() {
|
||||
</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">
|
||||
<Button
|
||||
variant={isMuted ? 'destructive' : 'default'}
|
||||
@@ -813,26 +1057,97 @@ export default function MeetingRoom() {
|
||||
</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 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">
|
||||
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>
|
||||
</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">
|
||||
{/* 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 da Reunião</span>
|
||||
</div>
|
||||
<span className="font-mono text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">Tempo Real</span>
|
||||
{/* Seletor de Abas */}
|
||||
<div className="flex border-b border-slate-800 bg-slate-950/40">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`flex-1 rounded-none border-b-2 font-mono text-[10px] uppercase h-12 gap-1.5 ${
|
||||
activeTab === 'participants' ? 'border-primary text-primary bg-slate-900/40' : 'border-transparent text-muted-foreground'
|
||||
}`}
|
||||
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>
|
||||
|
||||
{/* 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">
|
||||
{chatMessages.length === 0 ? (
|
||||
<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" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user