🚀 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 { 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>
);