1475 lines
61 KiB
TypeScript
1475 lines
61 KiB
TypeScript
import React, { useEffect, useRef, useState, useMemo, Suspense } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
|
|
interface ErrorBoundaryProps {
|
|
children: React.ReactNode;
|
|
fallback: React.ReactNode;
|
|
}
|
|
|
|
interface ErrorBoundaryState {
|
|
hasError: boolean;
|
|
}
|
|
|
|
class ModelErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
constructor(props: ErrorBoundaryProps) {
|
|
super(props);
|
|
this.state = { hasError: false };
|
|
}
|
|
|
|
static getDerivedStateFromError() {
|
|
return { hasError: true };
|
|
}
|
|
|
|
componentDidCatch(error: any, errorInfo: any) {
|
|
console.error("🚨 [ModelErrorBoundary] Falha de renderização/rede no modelo:", error, errorInfo);
|
|
}
|
|
|
|
render() {
|
|
if (this.state.hasError) {
|
|
return this.props.fallback;
|
|
}
|
|
return this.props.children;
|
|
}
|
|
}
|
|
import { Canvas, useFrame } 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 { Checkbox } from '@/components/ui/checkbox';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
Users, MessageSquare, Mic, MicOff, Home, Send,
|
|
ArrowRight, ChevronLeft, ChevronRight, Share2, LogOut,
|
|
RotateCw, Award, ShieldAlert
|
|
} from 'lucide-react';
|
|
|
|
// Interfaces tipadas para evitar any
|
|
interface AvatarPreset {
|
|
id: number;
|
|
name: string;
|
|
desc: string;
|
|
color: string;
|
|
}
|
|
|
|
interface PresentationState {
|
|
rotation: number[];
|
|
scale: number;
|
|
sectionEnabled: boolean;
|
|
sectionAxis: string;
|
|
sectionLevel: number;
|
|
sectionInvert: boolean;
|
|
}
|
|
|
|
interface PeerData {
|
|
username: string;
|
|
avatarId: number;
|
|
chairId: string;
|
|
role: string;
|
|
cargo: string;
|
|
empresa: string;
|
|
presence_ref: string;
|
|
}
|
|
|
|
// --- Configurações da Mesa de Reunião ---
|
|
const CHAIRS = [
|
|
{ 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] },
|
|
];
|
|
|
|
const AVATAR_PRESETS: AvatarPreset[] = [
|
|
{ id: 1, name: 'Cyber', desc: 'Cabelo curto preto e óculos vermelhos', color: '#ef4444' },
|
|
{ id: 2, name: 'Classic', desc: 'Pele média com Cabelo castanho clássico', color: '#b45309' },
|
|
{ id: 3, name: 'Goldie', desc: 'Cabelo loiro longo com olhos azuis expressivos', color: '#eab308' },
|
|
{ id: 4, name: 'Sporty', desc: 'Boné azul virado para trás e sorriso aberto', color: '#3b82f6' },
|
|
{ id: 5, name: 'Ginger', desc: 'Cabelo ruivo longo ondulado e expressivo', color: '#f97316' },
|
|
{ id: 6, name: 'Sleek', desc: 'Pele parda com cabelo curto liso lateral', color: '#10b981' },
|
|
{ id: 7, name: 'Beard', desc: 'Careca estilosa com barba e bigode', color: '#78350f' },
|
|
{ id: 8, name: 'Sun', desc: 'Cabelo raspado e óculos escuros pretos', color: '#1e293b' },
|
|
{ id: 9, name: 'Synth', desc: 'Cabelo roxo ondulado moderno', color: '#a855f7' },
|
|
{ id: 10, name: 'Straw', desc: 'Chapéu de palha do campo clássico', color: '#ca8a04' },
|
|
];
|
|
|
|
const SKIN_COLORS = ['#fed7aa', '#fdba74', '#f59e0b', '#d97706', '#9a3412', '#ffedd5'];
|
|
|
|
// Classes Tailwind estáticas para evitar inline styles no list view dos avatares
|
|
const getAvatarBgClass = (avatarId: number): string => {
|
|
const classes = [
|
|
'bg-red-500/20 text-red-400',
|
|
'bg-amber-800/20 text-amber-500',
|
|
'bg-yellow-500/20 text-yellow-400',
|
|
'bg-blue-500/20 text-blue-400',
|
|
'bg-orange-500/20 text-orange-400',
|
|
'bg-emerald-500/20 text-emerald-400',
|
|
'bg-amber-900/20 text-amber-600',
|
|
'bg-slate-700/20 text-slate-400',
|
|
'bg-purple-500/20 text-purple-400',
|
|
'bg-yellow-600/20 text-yellow-500'
|
|
];
|
|
return classes[(avatarId - 1) % 10] || classes[0];
|
|
};
|
|
|
|
// --- Subcomponentes 3D ---
|
|
|
|
// Avatar estilo Mii (Nintendo Wii) - Versão Melhorada (Mais Feliz e Expressivo)
|
|
function MiiAvatar({ avatarId, username, isTalking, scale = 1, ...props }: { avatarId: number; username: string; isTalking?: boolean; scale?: number } & Record<string, unknown>) {
|
|
const groupRef = useRef<THREE.Group>(null);
|
|
|
|
const mii = useMemo(() => {
|
|
const skin = SKIN_COLORS[(avatarId - 1) % SKIN_COLORS.length];
|
|
return {
|
|
skin,
|
|
hairColor: avatarId === 3 ? '#eab308' : avatarId === 9 ? '#a855f7' : avatarId === 5 ? '#f97316' : '#27272a',
|
|
hasGlasses: avatarId === 1 || avatarId === 8,
|
|
glassesColor: avatarId === 8 ? '#18181b' : '#ef4444',
|
|
hasCap: avatarId === 4,
|
|
capColor: '#3b82f6',
|
|
hasHat: avatarId === 10,
|
|
hatColor: '#b45309',
|
|
hairStyle: avatarId === 7 ? 'bald' : (avatarId === 9 ? 'modern' : (avatarId % 3 === 0 ? 'long' : 'short')),
|
|
beard: avatarId === 7,
|
|
};
|
|
}, [avatarId]);
|
|
|
|
useFrame((state) => {
|
|
if (groupRef.current && props.position && Array.isArray(props.position) && props.position.length >= 2) {
|
|
groupRef.current.position.y = (props.position[1] as number) + Math.sin(state.clock.getElapsedTime() * 2 + avatarId) * 0.012;
|
|
}
|
|
});
|
|
|
|
const positionVector = useMemo(() => {
|
|
if (props.position && Array.isArray(props.position)) {
|
|
return new THREE.Vector3(props.position[0] as number, props.position[1] as number, props.position[2] as number);
|
|
}
|
|
return new THREE.Vector3(0, 0, 0);
|
|
}, [props.position]);
|
|
|
|
const rotationEuler = useMemo(() => {
|
|
if (props.rotation && Array.isArray(props.rotation)) {
|
|
return new THREE.Euler(props.rotation[0] as number, props.rotation[1] as number, props.rotation[2] as number);
|
|
}
|
|
return new THREE.Euler(0, 0, 0);
|
|
}, [props.rotation]);
|
|
|
|
return (
|
|
<group ref={groupRef} position={positionVector} rotation={rotationEuler} scale={scale}>
|
|
{/* 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]} />
|
|
<meshBasicMaterial color="#0f172a" transparent opacity={0.7} />
|
|
</mesh>
|
|
<Text fontSize={0.024} color="#ffffff" anchorX="center" anchorY="middle" fontWeight="bold">
|
|
{username}
|
|
</Text>
|
|
</group>
|
|
|
|
{/* Indicador de Fala (anel pulsante) */}
|
|
{isTalking && (
|
|
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.12, 0]}>
|
|
<ringGeometry args={[0.13, 0.15, 32]} />
|
|
<meshBasicMaterial color="#3b82f6" transparent opacity={0.6 + Math.sin(Date.now() * 0.01) * 0.3} side={THREE.DoubleSide} />
|
|
</mesh>
|
|
)}
|
|
|
|
{/* CABEÇA AVATAR (Esfera ovalada de pele) */}
|
|
<mesh position={[0, 0.1, 0]} scale={[1, 1.15, 1]}>
|
|
<sphereGeometry args={[0.08, 32, 32]} />
|
|
<meshStandardMaterial color={mii.skin} roughness={0.6} />
|
|
</mesh>
|
|
|
|
{/* CABELO MELHORADO */}
|
|
{mii.hairStyle === 'short' && (
|
|
<group>
|
|
{/* Topo do cabelo */}
|
|
<mesh position={[0, 0.15, -0.015]} scale={[1.05, 0.8, 1.05]}>
|
|
<sphereGeometry args={[0.08, 16, 16]} />
|
|
<meshStandardMaterial color={mii.hairColor} roughness={0.8} />
|
|
</mesh>
|
|
{/* Costeletas laterais */}
|
|
<mesh position={[-0.078, 0.11, 0.02]} scale={[0.2, 0.5, 0.4]}>
|
|
<sphereGeometry args={[0.03, 8, 8]} />
|
|
<meshStandardMaterial color={mii.hairColor} roughness={0.8} />
|
|
</mesh>
|
|
<mesh position={[0.078, 0.11, 0.02]} scale={[0.2, 0.5, 0.4]}>
|
|
<sphereGeometry args={[0.03, 8, 8]} />
|
|
<meshStandardMaterial color={mii.hairColor} roughness={0.8} />
|
|
</mesh>
|
|
</group>
|
|
)}
|
|
|
|
{mii.hairStyle === 'long' && (
|
|
<group>
|
|
{/* Cabelo Superior */}
|
|
<mesh position={[0, 0.16, -0.01]} scale={[1.05, 0.7, 1.05]}>
|
|
<sphereGeometry args={[0.08, 16, 16]} />
|
|
<meshStandardMaterial color={mii.hairColor} roughness={0.8} />
|
|
</mesh>
|
|
{/* Franja frontal */}
|
|
<mesh position={[0, 0.16, 0.045]} scale={[1.3, 0.3, 0.4]} rotation={[0.1, 0, 0]}>
|
|
<sphereGeometry args={[0.04, 16, 16]} />
|
|
<meshStandardMaterial color={mii.hairColor} roughness={0.8} />
|
|
</mesh>
|
|
{/* Costeletas longas */}
|
|
<mesh position={[-0.076, 0.05, 0.01]} scale={[0.3, 1.6, 0.6]}>
|
|
<sphereGeometry args={[0.038, 16, 16]} />
|
|
<meshStandardMaterial color={mii.hairColor} roughness={0.8} />
|
|
</mesh>
|
|
<mesh position={[0.076, 0.05, 0.01]} scale={[0.3, 1.6, 0.6]}>
|
|
<sphereGeometry args={[0.038, 16, 16]} />
|
|
<meshStandardMaterial color={mii.hairColor} roughness={0.8} />
|
|
</mesh>
|
|
</group>
|
|
)}
|
|
|
|
{mii.hairStyle === 'modern' && (
|
|
<group>
|
|
{/* Cabelo Superior */}
|
|
<mesh position={[0, 0.16, -0.01]} scale={[1.05, 0.7, 1.05]}>
|
|
<sphereGeometry args={[0.08, 16, 16]} />
|
|
<meshStandardMaterial color={mii.hairColor} roughness={0.8} />
|
|
</mesh>
|
|
{/* Topete Moderno */}
|
|
<mesh position={[0, 0.19, 0.02]} rotation={[-0.3, 0, 0]} scale={[0.8, 1.2, 0.8]}>
|
|
<coneGeometry args={[0.04, 0.08, 4]} />
|
|
<meshStandardMaterial color={mii.hairColor} roughness={0.8} />
|
|
</mesh>
|
|
{/* Costeletas */}
|
|
<mesh position={[-0.076, 0.09, 0.01]} scale={[0.3, 1.0, 0.5]}>
|
|
<sphereGeometry args={[0.03, 8, 8]} />
|
|
<meshStandardMaterial color={mii.hairColor} roughness={0.8} />
|
|
</mesh>
|
|
<mesh position={[0.076, 0.09, 0.01]} scale={[0.3, 1.0, 0.5]}>
|
|
<sphereGeometry args={[0.03, 8, 8]} />
|
|
<meshStandardMaterial color={mii.hairColor} roughness={0.8} />
|
|
</mesh>
|
|
</group>
|
|
)}
|
|
|
|
{/* BONÉ */}
|
|
{mii.hasCap && (
|
|
<group position={[0, 0.17, 0.01]}>
|
|
{/* Copa do Boné */}
|
|
<mesh scale={[1.05, 0.65, 1.05]}>
|
|
<sphereGeometry args={[0.08, 16, 16]} />
|
|
<meshStandardMaterial color={mii.capColor} roughness={0.5} />
|
|
</mesh>
|
|
{/* Aba do Boné */}
|
|
<mesh position={[0, 0, -0.08]} rotation={[0.1, 0, 0]}>
|
|
<boxGeometry args={[0.11, 0.008, 0.08]} />
|
|
<meshStandardMaterial color={mii.capColor} roughness={0.5} />
|
|
</mesh>
|
|
</group>
|
|
)}
|
|
|
|
{/* CHAPÉU DE PALHA */}
|
|
{mii.hasHat && (
|
|
<group position={[0, 0.17, 0]}>
|
|
{/* Copa do Chapéu */}
|
|
<mesh>
|
|
<cylinderGeometry args={[0.055, 0.065, 0.05, 16]} />
|
|
<meshStandardMaterial color={mii.hatColor} roughness={0.8} />
|
|
</mesh>
|
|
{/* Aba do Chapéu */}
|
|
<mesh position={[0, -0.02, 0]}>
|
|
<cylinderGeometry args={[0.12, 0.12, 0.006, 16]} />
|
|
<meshStandardMaterial color={mii.hatColor} roughness={0.8} />
|
|
</mesh>
|
|
</group>
|
|
)}
|
|
|
|
{/* OLHOS MELHORADOS (Mais expressivos e com brilho) */}
|
|
<group position={[-0.028, 0.11, 0.07]} rotation={[0.08, -0.15, 0]}>
|
|
<mesh>
|
|
<sphereGeometry args={[0.011, 16, 16]} />
|
|
<meshBasicMaterial color="#111827" />
|
|
</mesh>
|
|
<mesh position={[0.004, 0.004, 0.008]}>
|
|
<sphereGeometry args={[0.004, 8, 8]} />
|
|
<meshBasicMaterial color="#ffffff" />
|
|
</mesh>
|
|
</group>
|
|
|
|
<group position={[0.028, 0.11, 0.07]} rotation={[0.08, 0.15, 0]}>
|
|
<mesh>
|
|
<sphereGeometry args={[0.011, 16, 16]} />
|
|
<meshBasicMaterial color="#111827" />
|
|
</mesh>
|
|
<mesh position={[0.004, 0.004, 0.008]}>
|
|
<sphereGeometry args={[0.004, 8, 8]} />
|
|
<meshBasicMaterial color="#ffffff" />
|
|
</mesh>
|
|
</group>
|
|
|
|
{/* SOBRANCELHAS */}
|
|
<mesh position={[-0.028, 0.128, 0.072]} rotation={[0.05, -0.15, 0.08]}>
|
|
<boxGeometry args={[0.028, 0.005, 0.005]} />
|
|
<meshBasicMaterial color="#18181b" />
|
|
</mesh>
|
|
<mesh position={[0.028, 0.128, 0.072]} rotation={[0.05, 0.15, -0.08]}>
|
|
<boxGeometry args={[0.028, 0.005, 0.005]} />
|
|
<meshBasicMaterial color="#18181b" />
|
|
</mesh>
|
|
|
|
{/* NARIZ */}
|
|
<mesh position={[0, 0.09, 0.076]} scale={[1, 1.2, 1.2]}>
|
|
<sphereGeometry args={[0.008, 8, 8]} />
|
|
<meshStandardMaterial color={mii.skin} roughness={0.6} />
|
|
</mesh>
|
|
|
|
{/* BOCHECHAS ROSADAS (Deixa o avatar super amigável e feliz) */}
|
|
<mesh position={[-0.042, 0.08, 0.071]} scale={[1, 0.6, 0.2]}>
|
|
<sphereGeometry args={[0.01, 16, 16]} />
|
|
<meshBasicMaterial color="#f43f5e" transparent opacity={0.5} />
|
|
</mesh>
|
|
<mesh position={[0.042, 0.08, 0.071]} scale={[1, 0.6, 0.2]}>
|
|
<sphereGeometry args={[0.01, 16, 16]} />
|
|
<meshBasicMaterial color="#f43f5e" transparent opacity={0.5} />
|
|
</mesh>
|
|
|
|
{/* BOCA FELIZ E SORRIDENTE (Sem aparência de triste) */}
|
|
<mesh position={[0, 0.062, 0.073]} rotation={[0, 0, Math.PI]}>
|
|
<torusGeometry args={[0.014, 0.003, 8, 24, Math.PI]} />
|
|
<meshBasicMaterial color="#b91c1c" />
|
|
</mesh>
|
|
|
|
{/* BARBA E BIGODE */}
|
|
{mii.beard && (
|
|
<group>
|
|
<mesh position={[0, 0.075, 0.075]}>
|
|
<boxGeometry args={[0.04, 0.006, 0.005]} />
|
|
<meshBasicMaterial color="#713f12" />
|
|
</mesh>
|
|
<mesh position={[0, 0.045, 0.072]} scale={[1.2, 1, 1]}>
|
|
<sphereGeometry args={[0.015, 8, 8]} />
|
|
<meshStandardMaterial color="#713f12" roughness={0.8} />
|
|
</mesh>
|
|
</group>
|
|
)}
|
|
|
|
{/* ÓCULOS */}
|
|
{mii.hasGlasses && (
|
|
<group position={[0, 0.11, 0.072]}>
|
|
<mesh position={[-0.028, 0, 0.002]}>
|
|
<ringGeometry args={[0.016, 0.019, 16]} />
|
|
<meshBasicMaterial color={mii.glassesColor} />
|
|
</mesh>
|
|
<mesh position={[0.028, 0, 0.002]}>
|
|
<ringGeometry args={[0.016, 0.019, 16]} />
|
|
<meshBasicMaterial color={mii.glassesColor} />
|
|
</mesh>
|
|
<mesh position={[0, 0, 0.002]}>
|
|
<boxGeometry args={[0.028, 0.004, 0.002]} />
|
|
<meshBasicMaterial color={mii.glassesColor} />
|
|
</mesh>
|
|
</group>
|
|
)}
|
|
</group>
|
|
);
|
|
}
|
|
|
|
// Carregador e Escalador Inteligente da Maquete Holográfica
|
|
function HologramModel({ model, presentationState }: { model: { url: string }; presentationState: PresentationState }) {
|
|
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;
|
|
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 targetSize = 0.95;
|
|
const scaleFactor = targetSize / maxDim;
|
|
groupRef.current.scale.setScalar(scaleFactor);
|
|
const center = new THREE.Vector3();
|
|
box.getCenter(center);
|
|
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) {
|
|
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;
|
|
}
|
|
|
|
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);
|
|
|
|
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} />
|
|
</group>
|
|
);
|
|
}
|
|
|
|
// Cenário da Sala de Reunião Clara
|
|
function ThreeMeetingRoomScene({ currentActiveModel, presentationState }: { currentActiveModel: { url: string; fileName: string } | null; presentationState: PresentationState }) {
|
|
const tableGeom = useMemo(() => {
|
|
// Geometria da mesa em U
|
|
const shape = new THREE.Shape();
|
|
shape.moveTo(-1.6, -1.1);
|
|
shape.lineTo(1.6, -1.1);
|
|
shape.quadraticCurveTo(1.9, -1.1, 1.9, -0.8);
|
|
shape.lineTo(1.9, 0.8);
|
|
shape.quadraticCurveTo(1.9, 1.1, 1.6, 1.1);
|
|
shape.lineTo(-1.6, 1.1);
|
|
shape.quadraticCurveTo(-1.9, 1.1, -1.9, 0.8);
|
|
shape.lineTo(-1.9, -0.8);
|
|
shape.quadraticCurveTo(-1.9, -1.1, -1.6, -1.1);
|
|
|
|
const hole = new THREE.Path();
|
|
hole.absellipse(0, 0, 0.9, 0.45, 0, Math.PI * 2, true);
|
|
shape.holes.push(hole);
|
|
|
|
return new THREE.ExtrudeGeometry(shape, { depth: 0.06, bevelEnabled: true, bevelThickness: 0.01, bevelSize: 0.01, bevelSegments: 3 });
|
|
}, []);
|
|
|
|
return (
|
|
<group>
|
|
{/* Piso 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} />
|
|
</mesh>
|
|
|
|
{/* Tapete Central */}
|
|
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.001, 0]}>
|
|
<planeGeometry args={[4.8, 3.2]} />
|
|
<meshStandardMaterial color="#94a3b8" roughness={0.7} />
|
|
</mesh>
|
|
|
|
{/* Paredes Claras */}
|
|
<mesh position={[0, 2, -4]}>
|
|
<planeGeometry args={[12, 4]} />
|
|
<meshStandardMaterial color="#f1f5f9" roughness={0.9} />
|
|
</mesh>
|
|
{/* 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} />
|
|
</mesh>
|
|
<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} />
|
|
</mesh>
|
|
|
|
{/* 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} />
|
|
</mesh>
|
|
</group>
|
|
|
|
{/* Pés da mesa (Cromados) */}
|
|
{[-1.3, 1.3].map((x) =>
|
|
[-0.7, 0.7].map((z) => (
|
|
<mesh key={`${x}-${z}`} position={[x, 0.4, z]}>
|
|
<cylinderGeometry args={[0.035, 0.035, 0.8]} />
|
|
<meshStandardMaterial color="#d4d4d8" metalness={0.9} roughness={0.1} />
|
|
</mesh>
|
|
))
|
|
)}
|
|
|
|
{/* Projetor de Holograma (Centro da mesa) */}
|
|
<group position={[0, 0.78, 0]}>
|
|
<mesh rotation={[-Math.PI / 2, 0, 0]}>
|
|
<cylinderGeometry args={[0.26, 0.28, 0.05, 32]} />
|
|
<meshStandardMaterial color="#1e293b" metalness={0.8} roughness={0.2} />
|
|
</mesh>
|
|
<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>
|
|
<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]}>
|
|
<ModelErrorBoundary fallback={
|
|
// Se falhar ao carregar o modelo (ex: blob local do host inacessível por convidados), renderiza o holograma geométrico
|
|
<group rotation={[0, Date.now() * 0.00012, 0]}>
|
|
<mesh position={[0, 0, 0]}>
|
|
<octahedronGeometry args={[0.22]} />
|
|
<meshStandardMaterial color="#f43f5e" transparent opacity={0.7} wireframe emissive="#f43f5e" emissiveIntensity={0.5} />
|
|
</mesh>
|
|
<mesh position={[0, -0.15, 0]}>
|
|
<boxGeometry args={[0.3, 0.02, 0.3]} />
|
|
<meshStandardMaterial color="#ef4444" transparent opacity={0.4} />
|
|
</mesh>
|
|
</group>
|
|
}>
|
|
<Suspense fallback={
|
|
<mesh>
|
|
<sphereGeometry args={[0.1, 16, 16]} />
|
|
<meshBasicMaterial color="#00f5ff" wireframe />
|
|
</mesh>
|
|
}>
|
|
{currentActiveModel ? (
|
|
<HologramModel model={currentActiveModel} presentationState={presentationState} />
|
|
) : (
|
|
// Holograma Padrão Geométrico se não houver maquete carregada
|
|
<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} />
|
|
</mesh>
|
|
<mesh position={[0, 0, 0]} rotation={[0, Math.PI / 4, 0]}>
|
|
<boxGeometry args={[0.25, 0.04, 0.25]} />
|
|
<meshStandardMaterial color="#3b82f6" transparent opacity={0.4} emissive="#3b82f6" emissiveIntensity={0.2} />
|
|
</mesh>
|
|
</group>
|
|
)}
|
|
</Suspense>
|
|
</ModelErrorBoundary>
|
|
</group>
|
|
</group>
|
|
|
|
{/* Renderizar as Cadeiras Físicas da mesa */}
|
|
{CHAIRS.map((chair) => (
|
|
<group key={chair.id} position={[chair.pos[0], 0, chair.pos[2]]} rotation={[0, Math.atan2(chair.lookAt[0] - chair.pos[0], chair.lookAt[2] - chair.pos[2]), 0]}>
|
|
{/* Base giratória metálica */}
|
|
<mesh position={[0, 0.25, 0]}>
|
|
<cylinderGeometry args={[0.015, 0.015, 0.5]} />
|
|
<meshStandardMaterial color="#a1a1aa" metalness={0.9} roughness={0.1} />
|
|
</mesh>
|
|
{/* Pés base */}
|
|
<mesh position={[0, 0.02, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
|
<ringGeometry args={[0, 0.22, 4]} />
|
|
<meshStandardMaterial color="#71717a" metalness={0.85} roughness={0.2} />
|
|
</mesh>
|
|
{/* Assento */}
|
|
<mesh position={[0, 0.52, 0]}>
|
|
<boxGeometry args={[0.28, 0.05, 0.26]} />
|
|
<meshStandardMaterial color="#334155" roughness={0.7} />
|
|
</mesh>
|
|
{/* Encosto */}
|
|
<mesh position={[0, 0.78, -0.11]} rotation={[0.08, 0, 0]}>
|
|
<boxGeometry args={[0.26, 0.44, 0.04]} />
|
|
<meshStandardMaterial color="#334155" roughness={0.7} />
|
|
</mesh>
|
|
</group>
|
|
))}
|
|
|
|
{/* Iluminação Clara da Sala */}
|
|
<hemisphereLight color="#ffffff" groundColor="#64748b" intensity={1.4} />
|
|
<directionalLight position={[6, 12, 6]} intensity={1.8} castShadow />
|
|
<directionalLight position={[-6, 8, -6]} intensity={0.5} />
|
|
<pointLight position={[0, 3.5, 0]} intensity={2.0} color="#0ea5e9" distance={8} />
|
|
</group>
|
|
);
|
|
}
|
|
|
|
// --- Componente Principal da Tela ---
|
|
|
|
export default function MeetingRoom() {
|
|
const { roomId } = useParams<{ roomId?: string }>();
|
|
const navigate = useNavigate();
|
|
const activeModel = useModelStore((s) => s.model);
|
|
|
|
// Estados do Lobby
|
|
const [inRoom, setInRoom] = useState(false);
|
|
const [name, setName] = useState('');
|
|
const [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 (Conexão Supabase)
|
|
const [peers, setPeers] = useState<Record<string, PeerData>>({});
|
|
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<PresentationState>({
|
|
rotation: [0, 0, 0],
|
|
scale: 1,
|
|
sectionEnabled: false,
|
|
sectionAxis: 'y',
|
|
sectionLevel: 0.0,
|
|
sectionInvert: false
|
|
});
|
|
|
|
// Carrega modificações da peça vindas do Viewer se houver
|
|
useEffect(() => {
|
|
const savedState = localStorage.getItem('tsxr_initial_meeting_state');
|
|
if (savedState) {
|
|
try {
|
|
const parsed = JSON.parse(savedState) as PresentationState;
|
|
setPresentationState(parsed);
|
|
console.log('🔄 [MeetingRoom] Estado inicial carregado do Viewer:', parsed);
|
|
} catch (err) {
|
|
console.error('Falha ao carregar estado inicial do Viewer', err);
|
|
}
|
|
localStorage.removeItem('tsxr_initial_meeting_state');
|
|
}
|
|
}, []);
|
|
|
|
const channelRef = useRef<ReturnType<typeof supabase.channel> | null>(null);
|
|
const chatScrollRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Se o roomId mudar na url, atualiza o checkbox isPresenter e o inputRoomId
|
|
useEffect(() => {
|
|
setIsPresenter(!roomId);
|
|
setInputRoomId(roomId || '');
|
|
}, [roomId]);
|
|
|
|
// Simulação de detecção de áudio
|
|
useEffect(() => {
|
|
if (!inRoom || isMuted) {
|
|
setIsTalking(false);
|
|
return;
|
|
}
|
|
const interval = setInterval(() => {
|
|
setIsTalking(Math.random() > 0.7);
|
|
}, 1500);
|
|
return () => clearInterval(interval);
|
|
}, [inRoom, isMuted]);
|
|
|
|
useEffect(() => {
|
|
if (chatScrollRef.current) {
|
|
chatScrollRef.current.scrollTop = chatScrollRef.current.scrollHeight;
|
|
}
|
|
}, [chatMessages]);
|
|
|
|
// --- ESCUTA DO LOBBY EM TEMPO REAL ---
|
|
// Conecta ao canal antes do lobby finalizar para ler assentos e avatares ocupados na sala digitada
|
|
useEffect(() => {
|
|
if (!inputRoomId || inputRoomId.trim().length < 3) {
|
|
setPeers({});
|
|
return;
|
|
}
|
|
|
|
const roomIdUpper = inputRoomId.trim().toUpperCase();
|
|
console.log(`🔌 [Lobby Watcher] Escutando sala: ${roomIdUpper}`);
|
|
|
|
const tempChannel = supabase.channel(`meeting_room_${roomIdUpper}`, {
|
|
config: {
|
|
presence: { key: 'lobby_watcher_' + Math.random().toString(36).substring(3, 8) }
|
|
}
|
|
});
|
|
|
|
tempChannel
|
|
.on('presence', { event: 'sync' }, () => {
|
|
const state = tempChannel.presenceState();
|
|
const formattedPeers: Record<string, PeerData> = {};
|
|
|
|
Object.keys(state).forEach((chairKey) => {
|
|
// Ignora outros watchers do lobby
|
|
if (chairKey.startsWith('lobby_watcher_')) return;
|
|
|
|
const presences = state[chairKey] as unknown as PeerData[];
|
|
if (presences && presences.length > 0) {
|
|
formattedPeers[chairKey] = presences[0];
|
|
}
|
|
});
|
|
|
|
setPeers(formattedPeers);
|
|
})
|
|
.subscribe();
|
|
|
|
return () => {
|
|
tempChannel.unsubscribe();
|
|
};
|
|
}, [inputRoomId, inRoom]);
|
|
|
|
// Envia as atualizações de apresentação via Broadcast para a sala inteira
|
|
const handlePresentationChange = (updatedFields: Partial<PresentationState>) => {
|
|
if (!isPresenter || !channelRef.current) return;
|
|
|
|
const newState = { ...presentationState, ...updatedFields } as PresentationState;
|
|
setPresentationState(newState);
|
|
|
|
channelRef.current.send({
|
|
type: 'broadcast',
|
|
event: 'presentation_update',
|
|
payload: newState
|
|
});
|
|
};
|
|
|
|
// Inicializa o Supabase Presence & Broadcast para conexões oficiais
|
|
const connectToRoom = async (targetRoomId: string) => {
|
|
if (!name.trim()) {
|
|
toast.error('Digite seu nome primeiro');
|
|
return;
|
|
}
|
|
|
|
// Validar se o avatar já está em uso por outra pessoa
|
|
const avatarInUse = Object.values(peers).find((p) => p.avatarId === selectedAvatar);
|
|
if (avatarInUse) {
|
|
toast.error(`O avatar "${AVATAR_PRESETS[selectedAvatar - 1].name}" já está em uso por ${avatarInUse.username}. Escolha outro!`);
|
|
return;
|
|
}
|
|
|
|
// Validar se a cadeira está ocupada
|
|
if (Object.keys(peers).includes(selectedChair)) {
|
|
toast.error('O assento selecionado já está ocupado por outro participante. Por favor, selecione outro assento.');
|
|
return;
|
|
}
|
|
|
|
const finalRoomId = targetRoomId.trim().toUpperCase() || Math.random().toString(36).substring(3, 8).toUpperCase();
|
|
setRoomCode(finalRoomId);
|
|
|
|
// Evita o erro "cannot add presence callbacks ... after subscribe()" removendo canais antigos com o mesmo tópico
|
|
const oldChannels = supabase.getChannels().filter(c => c.topic === `realtime:meeting_room_${finalRoomId}`);
|
|
for (const old of oldChannels) {
|
|
console.log(`🔌 [MeetingRoom] Removendo canal antigo ativo para evitar conflitos: ${old.topic}`);
|
|
await supabase.removeChannel(old);
|
|
}
|
|
|
|
console.log(`🔌 [MeetingRoom] Conectando à sala: ${finalRoomId} como ${isPresenter ? 'Apresentador' : 'Convidado'}`);
|
|
|
|
const channel = supabase.channel(`meeting_room_${finalRoomId}`, {
|
|
config: {
|
|
broadcast: { self: true },
|
|
presence: { key: selectedChair },
|
|
},
|
|
});
|
|
|
|
channelRef.current = channel;
|
|
|
|
// Sincroniza participantes online
|
|
channel
|
|
.on('presence', { event: 'sync' }, () => {
|
|
const state = channel.presenceState();
|
|
const formattedPeers: Record<string, PeerData> = {};
|
|
let count = 0;
|
|
|
|
Object.keys(state).forEach((chairKey) => {
|
|
if (chairKey.startsWith('lobby_watcher_')) return;
|
|
const presences = state[chairKey] as unknown as PeerData[];
|
|
if (presences && presences.length > 0) {
|
|
formattedPeers[chairKey] = presences[0];
|
|
count++;
|
|
}
|
|
});
|
|
setPeers(formattedPeers);
|
|
setActiveUsersCount(count);
|
|
})
|
|
.on('presence', { event: 'join' }, ({ key, newPresences }) => {
|
|
if (key.startsWith('lobby_watcher_')) return;
|
|
toast.info(`"${(newPresences[0] as unknown as PeerData)?.username}" sentou-se no assento ${key}`);
|
|
})
|
|
.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
|
|
if (key.startsWith('lobby_watcher_')) return;
|
|
toast.info(`"${(leftPresences[0] as unknown as PeerData)?.username}" liberou o assento ${key}`);
|
|
});
|
|
|
|
// Escuta mensagens do Chat via Broadcast
|
|
channel.on('broadcast', { event: 'chat_msg' }, (payload) => {
|
|
setChatMessages((prev) => [
|
|
...prev,
|
|
{
|
|
id: Math.random().toString(),
|
|
sender: payload.payload.sender,
|
|
text: payload.payload.text,
|
|
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
|
},
|
|
]);
|
|
});
|
|
|
|
// 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 as PresentationState);
|
|
}
|
|
});
|
|
|
|
// Inscrever no WebSocket
|
|
channel.subscribe(async (status) => {
|
|
if (status === 'SUBSCRIBED') {
|
|
// 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(),
|
|
});
|
|
|
|
// Se for o apresentador, propaga o estado inicial de apresentação (vinda do Viewer) para todos
|
|
if (isPresenter) {
|
|
channel.send({
|
|
type: 'broadcast',
|
|
event: 'presentation_update',
|
|
payload: presentationState
|
|
});
|
|
}
|
|
|
|
setInRoom(true);
|
|
toast.success(`Entrou na sala de reunião: ${finalRoomId}`);
|
|
navigate(`/meeting/${finalRoomId}`, { replace: true });
|
|
} else if (status === 'CHANNEL_ERROR') {
|
|
toast.error('Erro ao conectar ao canal de reuniões.');
|
|
}
|
|
});
|
|
};
|
|
|
|
// Enviar Mensagem de Chat
|
|
const handleSendMessage = () => {
|
|
if (!newMessage.trim() || !channelRef.current) return;
|
|
channelRef.current.send({
|
|
type: 'broadcast',
|
|
event: 'chat_msg',
|
|
payload: {
|
|
sender: name.substring(0, 10),
|
|
text: newMessage.trim(),
|
|
},
|
|
});
|
|
setNewMessage('');
|
|
};
|
|
|
|
// Sair da Sala de Reunião
|
|
const handleLeaveRoom = async () => {
|
|
if (channelRef.current) {
|
|
await supabase.removeChannel(channelRef.current);
|
|
}
|
|
setInRoom(false);
|
|
setPeers({});
|
|
setChatMessages([]);
|
|
navigate('/');
|
|
};
|
|
|
|
const occupiedChairs = useMemo(() => {
|
|
return Object.keys(peers).filter(k => !k.startsWith('lobby_watcher_'));
|
|
}, [peers]);
|
|
|
|
// Checar se o avatar selecionado no lobby está em uso
|
|
const isAvatarInUse = useMemo(() => {
|
|
return Object.values(peers).find((p) => p.avatarId === selectedAvatar);
|
|
}, [peers, selectedAvatar]);
|
|
|
|
const nextAvatar = () => {
|
|
setSelectedAvatar((prev) => (prev === 10 ? 1 : prev + 1));
|
|
};
|
|
const prevAvatar = () => {
|
|
setSelectedAvatar((prev) => (prev === 1 ? 10 : prev - 1));
|
|
};
|
|
|
|
// --- Renderização do Lobby ---
|
|
|
|
if (!inRoom) {
|
|
const currentAvatar = AVATAR_PRESETS[selectedAvatar - 1];
|
|
const canJoin = name.trim().length > 0 && !isAvatarInUse && !occupiedChairs.includes(selectedChair);
|
|
|
|
return (
|
|
<div className="flex min-h-screen flex-col items-center justify-center bg-background grid-industrial p-6">
|
|
<div className="absolute top-6 left-6 flex items-center gap-2">
|
|
<Button variant="ghost" size="icon" onClick={() => navigate('/')} className="text-muted-foreground hover:text-foreground">
|
|
<Home className="h-5 w-5" />
|
|
</Button>
|
|
<span className="font-mono text-xs uppercase tracking-widest text-muted-foreground">TrackSteelXR</span>
|
|
</div>
|
|
|
|
<div className="w-full max-w-4xl rounded-2xl border border-border bg-card/65 p-6 md:p-10 shadow-2xl backdrop-blur-md animate-in fade-in zoom-in-95 duration-200">
|
|
<div className="mb-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 text-xs font-mono uppercase tracking-wider text-muted-foreground">
|
|
Configure suas credenciais e escolha seu avatar
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-6 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-5 md:col-span-5 relative">
|
|
<span className="absolute top-3 left-4 font-mono text-[9px] uppercase tracking-widest text-muted-foreground">Visual Avatar</span>
|
|
|
|
{/* Enquadramento centralizado perfeito (Camera apontando e fov adequado) */}
|
|
<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.11, 0.28], fov: 42 }}>
|
|
<ambientLight intensity={0.8} />
|
|
<directionalLight position={[2, 3, 2]} intensity={1.5} />
|
|
<pointLight position={[0, 0.15, 0.2]} intensity={1} color={currentAvatar.color} />
|
|
<MiiAvatar avatarId={selectedAvatar} username={name || 'Você'} position={[0, -0.04, 0]} isTalking={true} scale={0.6} />
|
|
</Canvas>
|
|
|
|
{/* Aviso se o avatar estiver em uso na sala */}
|
|
{isAvatarInUse && (
|
|
<div className="absolute inset-0 bg-black/75 backdrop-blur-sm flex flex-col items-center justify-center text-center p-3 animate-in fade-in duration-200">
|
|
<ShieldAlert className="h-7 w-7 text-destructive animate-bounce mb-1" />
|
|
<span className="font-mono text-[10px] font-bold text-destructive uppercase tracking-wider">Avatar em Uso</span>
|
|
<span className="text-[9px] text-slate-300 mt-1 max-w-xs px-2 leading-snug">
|
|
Este visual já está ocupado por <strong className="text-primary uppercase block truncate">{isAvatarInUse.username}</strong>
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<Button variant="ghost" size="icon" className="absolute left-2 h-8 w-8 rounded-full border bg-background/50 hover:bg-background z-10" onClick={prevAvatar}>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="absolute right-2 h-8 w-8 rounded-full border bg-background/50 hover:bg-background z-10" onClick={nextAvatar}>
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="mt-3 text-center">
|
|
<span className="font-mono text-xs font-bold uppercase tracking-wider text-primary">
|
|
{currentAvatar.name}
|
|
</span>
|
|
<p className="text-[9px] text-muted-foreground mt-0.5">{currentAvatar.desc}</p>
|
|
</div>
|
|
|
|
{/* Formulário de Identificação: Nome, Cargo e Empresa agrupados sequencialmente na esquerda */}
|
|
<div className="mt-4 w-full space-y-3">
|
|
<div className="space-y-1">
|
|
<label className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground block">Seu Nome (Máx 10 letras)</label>
|
|
<Input
|
|
type="text"
|
|
maxLength={10}
|
|
placeholder="Nome do usuário"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
className="font-semibold text-center uppercase font-mono tracking-wider text-xs h-9"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<label className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground block">Cargo / Função</label>
|
|
<Input
|
|
type="text"
|
|
placeholder="Ex: Eng. Civil"
|
|
value={cargo}
|
|
onChange={(e) => setCargo(e.target.value)}
|
|
className="text-xs h-9 text-center"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1">
|
|
<label className="font-mono text-[9px] uppercase tracking-wider text-muted-foreground block">Empresa</label>
|
|
<Input
|
|
type="text"
|
|
placeholder="Ex: Steel Co."
|
|
value={empresa}
|
|
onChange={(e) => setEmpresa(e.target.value)}
|
|
className="text-xs h-9 text-center"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Seleção do Assento, Host e Conexão (Lado Direito - 7 colunas) */}
|
|
<div className="flex flex-col justify-between md:col-span-7 space-y-5">
|
|
{/* Seleção do Assento (Exibe quem está ocupando em tempo real) */}
|
|
<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-2 sm:grid-cols-4 gap-2">
|
|
{CHAIRS.map((chair) => {
|
|
const occupier = Object.values(peers).find((p) => p.chairId === chair.id);
|
|
const isOccupied = !!occupier;
|
|
const isSelected = selectedChair === chair.id;
|
|
return (
|
|
<Button
|
|
key={chair.id}
|
|
variant={isSelected ? 'default' : 'outline'}
|
|
className={`h-12 flex-col p-1 gap-0.5 text-[9px] font-mono ${
|
|
isOccupied ? 'border-destructive/40 text-destructive bg-destructive/10 cursor-not-allowed hover:bg-destructive/10' : ''
|
|
}`}
|
|
disabled={isOccupied}
|
|
onClick={() => setSelectedChair(chair.id)}
|
|
>
|
|
<span className="font-semibold">{chair.name}</span>
|
|
<span className={`text-[8px] truncate max-w-full font-bold uppercase ${
|
|
isOccupied ? 'text-destructive' : isSelected ? 'text-primary-foreground' : 'text-muted-foreground'
|
|
}`}>
|
|
{isOccupied ? occupier.username : isSelected ? 'Escolhido' : 'Livre'}
|
|
</span>
|
|
</Button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Papel e Código de Conexão */}
|
|
<div className="rounded-xl border bg-muted/20 p-4 space-y-4">
|
|
<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 text-xs h-9"
|
|
/>
|
|
</div>
|
|
|
|
<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 h-10 gap-2"
|
|
disabled={!canJoin}
|
|
onClick={() => connectToRoom(inputRoomId)}
|
|
>
|
|
Acessar Reunião <ArrowRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
|
|
{isAvatarInUse && (
|
|
<p className="text-[10px] text-destructive font-mono text-center">
|
|
* Escolha outro avatar para liberar o acesso.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Renderização da Sala de Reunião Ativa ---
|
|
|
|
return (
|
|
<div className="flex h-screen overflow-hidden bg-slate-950 text-white relative 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], fov: 45 }} shadows gl={{ localClippingEnabled: true }}>
|
|
<ThreeMeetingRoomScene currentActiveModel={activeModel} presentationState={presentationState} />
|
|
|
|
{/* Renderizar avatares de TODOS na sala */}
|
|
{Object.keys(peers).map((chairKey) => {
|
|
const peer = peers[chairKey];
|
|
const chairConf = CHAIRS.find((c) => c.id === peer.chairId);
|
|
if (!chairConf) return null;
|
|
|
|
const isLocalUser = peer.chairId === selectedChair;
|
|
|
|
return (
|
|
<MiiAvatar
|
|
key={peer.presence_ref}
|
|
avatarId={peer.avatarId}
|
|
username={isLocalUser ? `${peer.username} (Você)` : peer.username}
|
|
position={chairConf.pos}
|
|
isTalking={isLocalUser ? isTalking : (isTalking && Math.random() > 0.5)}
|
|
scale={1.2}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
<OrbitControls
|
|
makeDefault
|
|
enableDamping
|
|
dampingFactor={0.05}
|
|
minDistance={0.5}
|
|
maxDistance={8}
|
|
maxPolarAngle={Math.PI / 2 - 0.05}
|
|
target={[0, 0.95, 0]}
|
|
/>
|
|
</Canvas>
|
|
|
|
{/* 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 font-semibold">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}
|
|
title="Giro Horizontal"
|
|
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}
|
|
title="Zoom da Peça"
|
|
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"
|
|
title="Habilitar Plano de Corte"
|
|
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}
|
|
title="Nível de Corte"
|
|
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"
|
|
title="Inverter Plano de Corte"
|
|
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">
|
|
Online: {activeUsersCount}/8
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pointer-events-auto flex items-center gap-3">
|
|
{/* Código da sala */}
|
|
<div className="flex items-center gap-1.5 rounded-lg border border-border bg-slate-900/90 backdrop-blur-md px-3 py-1.5 shadow-2xl">
|
|
<span className="font-mono text-[10px] text-muted-foreground">CÓDIGO:</span>
|
|
<span className="font-mono text-xs font-bold text-primary tracking-widest">{roomCode}</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-5 w-5 text-muted-foreground hover:text-foreground"
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(window.location.href);
|
|
toast.success('Link de convite copiado!');
|
|
}}
|
|
title="Copiar link do convite"
|
|
>
|
|
<Share2 className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Sair */}
|
|
<Button variant="destructive" size="sm" className="font-mono text-[10px] gap-1.5 h-8 px-3" onClick={handleLeaveRoom}>
|
|
<LogOut className="h-3.5 w-3.5" /> Sair
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* HUD Inferior - Controles de Áudio */}
|
|
<div className="absolute bottom-4 left-4 pointer-events-none z-10 flex gap-2">
|
|
<Button
|
|
variant={isMuted ? 'destructive' : 'default'}
|
|
size="icon"
|
|
className="pointer-events-auto h-10 w-10 rounded-xl shadow-2xl border"
|
|
onClick={() => setIsMuted(!isMuted)}
|
|
title={isMuted ? 'Ativar microfone' : 'Mutar microfone'}
|
|
>
|
|
{isMuted ? <MicOff className="h-4.5 w-4.5" /> : <Mic className="h-4.5 w-4.5" />}
|
|
</Button>
|
|
|
|
<div className="pointer-events-auto rounded-xl border bg-slate-900/90 backdrop-blur-md px-4 py-2 flex flex-col justify-center shadow-2xl">
|
|
<span className="font-mono text-[8px] text-muted-foreground uppercase">
|
|
{isPresenter ? 'Apresentação Ativa' : 'Observador'}
|
|
</span>
|
|
<span className="text-[10px] text-foreground font-mono">
|
|
{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 (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">
|
|
{/* 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>
|
|
|
|
{/* 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 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 via classe estática Tailwind */}
|
|
<div className={`h-8 w-8 rounded-full border border-slate-800 flex items-center justify-center font-bold text-xs ${getAvatarBgClass(peer.avatarId)}`}>
|
|
{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">
|
|
<MessageSquare className="h-8 w-8 opacity-30 text-primary" />
|
|
<p className="text-[10px] font-mono leading-relaxed">
|
|
Nenhuma mensagem enviada. Seja o primeiro a digitar no chat da reunião!
|
|
</p>
|
|
</div>
|
|
) : (
|
|
chatMessages.map((msg) => (
|
|
<div key={msg.id} className="space-y-0.5">
|
|
<div className="flex items-baseline justify-between">
|
|
<span className="font-mono text-[10px] font-bold text-primary uppercase">{msg.sender}</span>
|
|
<span className="text-[8px] text-slate-500 font-mono">{msg.time}</span>
|
|
</div>
|
|
<p className="text-xs bg-slate-800/60 border border-slate-700/30 rounded-lg p-2 leading-relaxed text-slate-300">
|
|
{msg.text}
|
|
</p>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* Input do Chat */}
|
|
<div className="p-3 border-t border-slate-800 bg-slate-900 flex gap-2">
|
|
<Input
|
|
type="text"
|
|
placeholder="Digite sua mensagem..."
|
|
value={newMessage}
|
|
onChange={(e) => setNewMessage(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') handleSendMessage();
|
|
}}
|
|
className="flex-1 text-xs border-slate-800 bg-slate-950/65"
|
|
/>
|
|
<Button variant="default" size="icon" className="h-9 w-9 bg-primary" onClick={handleSendMessage}>
|
|
<Send className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|