🚀 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 { 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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user