2040 lines
86 KiB
TypeScript
2040 lines
86 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: Error, errorInfo: React.ErrorInfo) {
|
|
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, Billboard, Html } from '@react-three/drei';
|
|
import * as THREE from 'three';
|
|
import { supabase } from '@/integrations/supabase/client';
|
|
import { useModelStore, type SceneModel } 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, Maximize2, Minimize2, Box,
|
|
Settings, Check
|
|
} from 'lucide-react';
|
|
|
|
// Interfaces tipadas para evitar any
|
|
interface AvatarPreset {
|
|
id: number;
|
|
name: string;
|
|
desc: string;
|
|
color: string;
|
|
url: string;
|
|
}
|
|
|
|
interface PresentationState {
|
|
rotation: number[];
|
|
scale: number;
|
|
sectionEnabled: boolean;
|
|
sectionAxis: 'x' | 'y' | 'z';
|
|
sectionLevel: number;
|
|
sectionInvert: boolean;
|
|
modelUrl?: string;
|
|
modelFileName?: string;
|
|
modelFileSize?: number;
|
|
}
|
|
|
|
interface PeerData {
|
|
username: string;
|
|
avatarId: number;
|
|
chairId: string;
|
|
role: string;
|
|
cargo: string;
|
|
empresa: string;
|
|
presence_ref: string;
|
|
allowedAvatars?: number[];
|
|
}
|
|
|
|
// Detector nativo de suporte ao WebGL
|
|
function isWebGLAvailable() {
|
|
try {
|
|
const canvas = document.createElement('canvas');
|
|
return !!(
|
|
window.WebGLRenderingContext &&
|
|
(canvas.getContext('webgl') || canvas.getContext('experimental-webgl'))
|
|
);
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Hook de verificação de suporte a WebGL no cliente
|
|
function useWebGLSupport() {
|
|
const [supported, setSupported] = useState(true);
|
|
useEffect(() => {
|
|
setSupported(isWebGLAvailable());
|
|
}, []);
|
|
return supported;
|
|
}
|
|
|
|
// --- Configurações da Mesa de Reunião ---
|
|
const CHAIRS = [
|
|
{ id: '1', name: 'Assento 1', pos: [-1.2, 0.95, -1.45], rot: [0, 0, 0], lookAt: [0, 0.95, 0] },
|
|
{ id: '2', name: 'Assento 2', pos: [-0.4, 0.95, -1.45], rot: [0, 0, 0], lookAt: [0, 0.95, 0] },
|
|
{ id: '3', name: 'Assento 3', pos: [0.4, 0.95, -1.45], rot: [0, 0, 0], lookAt: [0, 0.95, 0] },
|
|
{ id: '4', name: 'Assento 4', pos: [1.2, 0.95, -1.45], rot: [0, 0, 0], lookAt: [0, 0.95, 0] },
|
|
{ id: '5', name: 'Assento 5', pos: [1.2, 0.95, 1.45], rot: [0, Math.PI, 0], lookAt: [0, 0.95, 0] },
|
|
{ id: '6', name: 'Assento 6', pos: [0.4, 0.95, 1.45], rot: [0, Math.PI, 0], lookAt: [0, 0.95, 0] },
|
|
{ id: '7', name: 'Assento 7', pos: [-0.4, 0.95, 1.45], rot: [0, Math.PI, 0], lookAt: [0, 0.95, 0] },
|
|
{ id: '8', name: 'Assento 8', pos: [-1.2, 0.95, 1.45], rot: [0, Math.PI, 0], lookAt: [0, 0.95, 0] },
|
|
];
|
|
|
|
const AVATAR_PRESETS: AvatarPreset[] = [
|
|
// Masculinos (1 a 10)
|
|
{ id: 1, name: 'Leo', desc: 'Negro, cabelo curto preto', url: '/avatars/avatar_1.png', color: '#ef4444' },
|
|
{ id: 2, name: 'Marcus', desc: 'Loiro, cabelo curto', url: '/avatars/avatar_2.png', color: '#b45309' },
|
|
{ id: 3, name: 'Arthur', desc: 'Ruivo com barba', url: '/avatars/avatar_3.png', color: '#eab308' },
|
|
{ id: 4, name: 'Jin', desc: 'Asiático, cabelo preto liso', url: '/avatars/avatar_4.png', color: '#3b82f6' },
|
|
{ id: 5, name: 'Mateo', desc: 'Castanho, cabelo cacheado', url: '/avatars/avatar_5.png', color: '#f97316' },
|
|
{ id: 6, name: 'Malik', desc: 'Negro, careca com barba', url: '/avatars/avatar_6.png', color: '#84cc16' },
|
|
{ id: 7, name: 'Oliver', desc: 'Cabelo grisalho com óculos', url: '/avatars/avatar_7.png', color: '#06b6d4' },
|
|
{ id: 8, name: 'Carlos', desc: 'Latino com bigode', url: '/avatars/avatar_8.png', color: '#6366f1' },
|
|
{ id: 9, name: 'Yuki', desc: 'Cabelo espetado azul escuro', url: '/avatars/avatar_9.png', color: '#a855f7' },
|
|
{ id: 10, name: 'Elijah', desc: 'Negro com dreadlocks', url: '/avatars/avatar_10.png', color: '#ec4899' },
|
|
// Femininos (11 a 20)
|
|
{ id: 11, name: 'Sophia', desc: 'Morena, cabelo longo castanho', url: '/avatars/avatar_11.png', color: '#10b981' },
|
|
{ id: 12, name: 'Aisha', desc: 'Negra com turbante/lenço', url: '/avatars/avatar_12.png', color: '#78350f' },
|
|
{ id: 13, name: 'Isabella', desc: 'Loira, cabelo longo ondulado', url: '/avatars/avatar_13.png', color: '#1e293b' },
|
|
{ id: 14, name: 'Chloe', desc: 'Ruiva, corte canal/bob', url: '/avatars/avatar_14.png', color: '#ca8a04' },
|
|
{ id: 15, name: 'Mei', desc: 'Asiática, cabelo com franja', url: '/avatars/avatar_15.png', color: '#f43f5e' },
|
|
{ id: 16, name: 'Elena', desc: 'Castanha, cabelo afro volumoso', url: '/avatars/avatar_16.png', color: '#14b8a6' },
|
|
{ id: 17, name: 'Zara', desc: 'Cabelo roxo moderno', url: '/avatars/avatar_17.png', color: '#0ea5e9' },
|
|
{ id: 18, name: 'Naomi', desc: 'Negra de óculos e rabo de cavalo', url: '/avatars/avatar_18.png', color: '#4f46e5' },
|
|
{ id: 19, name: 'Harper', desc: 'Corte pixie rosa', url: '/avatars/avatar_19.png', color: '#d946ef' },
|
|
{ id: 20, name: 'Amara', desc: 'Negra com tranças', url: '/avatars/avatar_20.png', color: '#f59e0b' }
|
|
];
|
|
|
|
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-lime-500/20 text-lime-400',
|
|
'bg-cyan-500/20 text-cyan-400',
|
|
'bg-indigo-500/20 text-indigo-400',
|
|
'bg-purple-500/20 text-purple-400',
|
|
'bg-pink-500/20 text-pink-400',
|
|
'bg-emerald-500/20 text-emerald-400',
|
|
'bg-amber-950/40 text-amber-600',
|
|
'bg-slate-700/20 text-slate-300',
|
|
'bg-yellow-600/20 text-yellow-500',
|
|
'bg-rose-500/20 text-rose-400',
|
|
'bg-teal-500/20 text-teal-400',
|
|
'bg-sky-500/20 text-sky-400',
|
|
'bg-indigo-600/20 text-indigo-500',
|
|
'bg-fuchsia-500/20 text-fuchsia-400',
|
|
'bg-amber-500/20 text-amber-400'
|
|
];
|
|
return classes[(avatarId - 1) % classes.length] || classes[0];
|
|
};
|
|
|
|
// --- Subcomponentes 3D ---
|
|
|
|
// ==========================================
|
|
// BACKUP DOS AVATARES PROCEDURAIS ORIGINAIS
|
|
// ==========================================
|
|
function MiiAvatarBackup({
|
|
avatarId,
|
|
username,
|
|
isTalking,
|
|
scale = 1,
|
|
isLastChatSender = false,
|
|
...props
|
|
}: {
|
|
avatarId: number;
|
|
username: string;
|
|
isTalking?: boolean;
|
|
scale?: number;
|
|
isLastChatSender?: boolean;
|
|
} & Record<string, unknown>) {
|
|
const groupRef = useRef<THREE.Group>(null);
|
|
const ringRef = useRef<THREE.Mesh>(null);
|
|
const headRef = useRef<THREE.Group>(null);
|
|
const lastSenderRef = useRef(isLastChatSender);
|
|
const pulseEndTimeRef = useRef(0);
|
|
|
|
useEffect(() => {
|
|
if (isLastChatSender && !lastSenderRef.current) {
|
|
pulseEndTimeRef.current = Date.now() + 1500;
|
|
}
|
|
lastSenderRef.current = isLastChatSender;
|
|
}, [isLastChatSender]);
|
|
|
|
const avatarUrl = useMemo(() => {
|
|
const preset = AVATAR_PRESETS.find((p) => p.id === avatarId);
|
|
return preset ? preset.url : `/avatars/avatar_${avatarId}.png`;
|
|
}, [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;
|
|
}
|
|
|
|
if (headRef.current) {
|
|
if (isTalking) {
|
|
headRef.current.position.y = 0.16 + Math.abs(Math.sin(state.clock.getElapsedTime() * 12)) * 0.025;
|
|
headRef.current.rotation.z = Math.sin(state.clock.getElapsedTime() * 12) * 0.06;
|
|
} else {
|
|
headRef.current.position.y = THREE.MathUtils.lerp(headRef.current.position.y, 0.16, 0.15);
|
|
headRef.current.rotation.z = THREE.MathUtils.lerp(headRef.current.rotation.z, 0, 0.15);
|
|
}
|
|
}
|
|
|
|
if (ringRef.current) {
|
|
const now = Date.now();
|
|
const material = ringRef.current.material as THREE.MeshBasicMaterial;
|
|
|
|
if (isTalking) {
|
|
material.color.set('#3b82f6');
|
|
material.opacity = 0.6 + Math.sin(state.clock.getElapsedTime() * 10) * 0.3;
|
|
ringRef.current.scale.set(1, 1, 1);
|
|
} else if (isLastChatSender) {
|
|
material.color.set('#eab308');
|
|
const isPulsing = now < pulseEndTimeRef.current;
|
|
if (isPulsing) {
|
|
material.opacity = 0.5 + Math.sin(state.clock.getElapsedTime() * 25) * 0.4;
|
|
const s = 1.0 + Math.sin(state.clock.getElapsedTime() * 25) * 0.15;
|
|
ringRef.current.scale.set(s, s, 1);
|
|
} else {
|
|
material.opacity = 0.75;
|
|
ringRef.current.scale.set(1, 1, 1);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
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}>
|
|
<group position={[0, 0.38, 0]}>
|
|
<mesh position={[0, 0, -0.001]}>
|
|
<planeGeometry args={[0.44, 0.1]} />
|
|
<meshBasicMaterial color="#0f172a" transparent opacity={0.7} />
|
|
</mesh>
|
|
<Text fontSize={0.048} color="#ffffff" anchorX="center" anchorY="middle" fontWeight="bold">
|
|
{username}
|
|
</Text>
|
|
</group>
|
|
|
|
{(isTalking || isLastChatSender) && (
|
|
<mesh ref={ringRef} rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.01, 0]}>
|
|
<ringGeometry args={[0.065, 0.075, 32]} />
|
|
<meshBasicMaterial color="#3b82f6" transparent opacity={0.7} side={THREE.DoubleSide} />
|
|
</mesh>
|
|
)}
|
|
|
|
{/* Cabeça do Avatar: Toon Head do Dicebear em HTML Transformado em 3D Plano (olhando para a mesa) */}
|
|
<group ref={headRef} position={[0, 0.16, 0]}>
|
|
<Html transform center distanceFactor={1.2}>
|
|
<div className="w-16 h-16 flex items-center justify-center pointer-events-none select-none">
|
|
<img
|
|
src={avatarUrl}
|
|
alt={username}
|
|
className="w-full h-full object-contain drop-shadow-md"
|
|
onError={(e) => {
|
|
(e.currentTarget as HTMLImageElement).src = `https://ui-avatars.com/api/?name=${encodeURIComponent(username || 'U')}&background=475569&color=fff&size=128&rounded=true`;
|
|
}}
|
|
/>
|
|
</div>
|
|
</Html>
|
|
</group>
|
|
|
|
{/* Base cônica para dar uma sustentação 3D estilizada */}
|
|
<mesh position={[0, 0.03, 0]}>
|
|
<coneGeometry args={[0.045, 0.1, 32]} />
|
|
<meshStandardMaterial color="#475569" roughness={0.5} metalness={0.2} transparent opacity={0.8} />
|
|
</mesh>
|
|
</group>
|
|
);
|
|
}
|
|
|
|
// ==========================================
|
|
// CLASSES E COMPONENTES DE RESILIÊNCIA
|
|
// ==========================================
|
|
class AvatarErrorBoundary extends React.Component<{ fallback: React.ReactNode; children: React.ReactNode }, { hasError: boolean }> {
|
|
constructor(props: { fallback: React.ReactNode; children: React.ReactNode }) {
|
|
super(props);
|
|
this.state = { hasError: false };
|
|
}
|
|
|
|
static getDerivedStateFromError() {
|
|
return { hasError: true };
|
|
}
|
|
|
|
componentDidCatch(error: Error) {
|
|
console.warn("🚨 [AvatarErrorBoundary] Falha ao carregar o modelo GLB do avatar. Utilizando o avatar de fallback procedural.", error);
|
|
}
|
|
|
|
render() {
|
|
if (this.state.hasError) {
|
|
return this.props.fallback;
|
|
}
|
|
return this.props.children;
|
|
}
|
|
}
|
|
|
|
// O componente principal MiiAvatar agora é um wrapper resiliente com Suspense e ErrorBoundary
|
|
function MiiAvatar(props: {
|
|
avatarId: number;
|
|
username: string;
|
|
isTalking?: boolean;
|
|
scale?: number;
|
|
isLastChatSender?: boolean;
|
|
} & Record<string, unknown>) {
|
|
const preset = AVATAR_PRESETS.find((p) => p.id === props.avatarId);
|
|
const isPng = preset?.url.endsWith('.png');
|
|
|
|
if (isPng) {
|
|
return <MiiAvatarBackup {...props} />;
|
|
}
|
|
|
|
return (
|
|
<AvatarErrorBoundary fallback={<MiiAvatarBackup {...props} />}>
|
|
<Suspense fallback={<MiiAvatarBackup {...props} />}>
|
|
<RealMiiAvatar {...props} />
|
|
</Suspense>
|
|
</AvatarErrorBoundary>
|
|
);
|
|
}
|
|
|
|
// ==========================================
|
|
// NOVOS AVATARES REALISTAS (READY PLAYER ME)
|
|
// ==========================================
|
|
function RealMiiAvatar({
|
|
avatarId,
|
|
username,
|
|
isTalking,
|
|
scale = 1,
|
|
isLastChatSender = false,
|
|
...props
|
|
}: {
|
|
avatarId: number;
|
|
username: string;
|
|
isTalking?: boolean;
|
|
scale?: number;
|
|
isLastChatSender?: boolean;
|
|
} & Record<string, unknown>) {
|
|
const groupRef = useRef<THREE.Group>(null);
|
|
const ringRef = useRef<THREE.Mesh>(null);
|
|
const lastSenderRef = useRef(isLastChatSender);
|
|
const pulseEndTimeRef = useRef(0);
|
|
|
|
// Monitora quando este avatar se torna o último a escrever no chat
|
|
useEffect(() => {
|
|
if (isLastChatSender && !lastSenderRef.current) {
|
|
pulseEndTimeRef.current = Date.now() + 1500;
|
|
}
|
|
lastSenderRef.current = isLastChatSender;
|
|
}, [isLastChatSender]);
|
|
|
|
// Obtém o preset correto para pegar a URL do avatar GLB
|
|
const preset = useMemo(() => {
|
|
return AVATAR_PRESETS.find((p) => p.id === avatarId) || AVATAR_PRESETS[0];
|
|
}, [avatarId]);
|
|
|
|
// Carrega o modelo 3D GLB de forma assíncrona
|
|
const { scene } = useGLTF(preset.url);
|
|
const clone = useMemo(() => scene.clone(), [scene]);
|
|
|
|
// Configuração inicial do clone para sombra e materiais
|
|
useEffect(() => {
|
|
if (clone) {
|
|
clone.traverse((child) => {
|
|
if (child instanceof THREE.Mesh) {
|
|
child.castShadow = true;
|
|
child.receiveShadow = true;
|
|
if (child.material) {
|
|
child.material.roughness = 0.8;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}, [clone]);
|
|
|
|
useFrame((state) => {
|
|
// Flutuação sutil de respiração/repouso
|
|
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() * 1.5 + avatarId) * 0.008 - 0.22;
|
|
}
|
|
|
|
// Animação de sincronização labial (lip-sync) nativa baseada em Morph Targets
|
|
if (clone) {
|
|
clone.traverse((child) => {
|
|
if (child instanceof THREE.SkinnedMesh && child.morphTargetInfluences) {
|
|
const dict = child.morphTargetDictionary;
|
|
if (dict) {
|
|
const jawOpenIdx = dict['jawOpen'] ?? dict['viseme_sil'] ?? dict['mouthOpen'];
|
|
const mouthSmileIdx = dict['mouthSmile'] ?? dict['mouthSmileLeft'];
|
|
|
|
if (jawOpenIdx !== undefined) {
|
|
if (isTalking) {
|
|
// Abre e fecha a boca no ritmo do clock
|
|
child.morphTargetInfluences[jawOpenIdx] = 0.15 + Math.abs(Math.sin(state.clock.getElapsedTime() * 18)) * 0.6;
|
|
} else {
|
|
child.morphTargetInfluences[jawOpenIdx] = 0;
|
|
}
|
|
}
|
|
|
|
if (mouthSmileIdx !== undefined) {
|
|
child.morphTargetInfluences[mouthSmileIdx] = 0.25;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Animação do anel de fala / chat
|
|
if (ringRef.current) {
|
|
const now = Date.now();
|
|
const material = ringRef.current.material as THREE.MeshBasicMaterial;
|
|
|
|
if (isTalking) {
|
|
material.color.set('#3b82f6');
|
|
material.opacity = 0.6 + Math.sin(state.clock.getElapsedTime() * 10) * 0.3;
|
|
ringRef.current.scale.set(1, 1, 1);
|
|
} else if (isLastChatSender) {
|
|
material.color.set('#eab308');
|
|
|
|
const isPulsing = now < pulseEndTimeRef.current;
|
|
if (isPulsing) {
|
|
material.opacity = 0.5 + Math.sin(state.clock.getElapsedTime() * 25) * 0.4;
|
|
const s = 1.0 + Math.sin(state.clock.getElapsedTime() * 25) * 0.15;
|
|
ringRef.current.scale.set(s, s, 1);
|
|
} else {
|
|
material.opacity = 0.75;
|
|
ringRef.current.scale.set(1, 1, 1);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
const positionVector = useMemo(() => {
|
|
if (props.position && Array.isArray(props.position)) {
|
|
return new THREE.Vector3(props.position[0] as number, (props.position[1] as number) - 0.22, props.position[2] as number);
|
|
}
|
|
return new THREE.Vector3(0, -0.22, 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.9, 0]}>
|
|
<mesh position={[0, 0, -0.001]}>
|
|
<planeGeometry args={[0.52, 0.11]} />
|
|
<meshBasicMaterial color="#0f172a" transparent opacity={0.8} />
|
|
</mesh>
|
|
<Text fontSize={0.052} color="#ffffff" anchorX="center" anchorY="middle" fontWeight="bold">
|
|
{username}
|
|
</Text>
|
|
</group>
|
|
|
|
{/* Modelo GLB Realista Clonado */}
|
|
<primitive object={clone} scale={0.42} position={[0, 0.02, 0]} />
|
|
|
|
{/* Indicador de Fala & Chat */}
|
|
{(isTalking || isLastChatSender) && (
|
|
<mesh
|
|
ref={ringRef}
|
|
rotation={[-Math.PI / 2, 0, 0]}
|
|
position={[0, 0.35, 0.02]}
|
|
>
|
|
<ringGeometry args={[0.075, 0.088, 32]} />
|
|
<meshBasicMaterial color="#3b82f6" transparent opacity={0.7} side={THREE.DoubleSide} />
|
|
</mesh>
|
|
)}
|
|
</group>
|
|
);
|
|
}
|
|
|
|
// Carregador e Escalador Inteligente da Maquete Holográfica
|
|
// Carregador e Escalador Inteligente da Maquete Holográfica
|
|
function HologramModel({ model, presentationState }: { model: SceneModel; presentationState: PresentationState }) {
|
|
const { scene } = useGLTF(model.url);
|
|
const clone = useMemo(() => scene.clone(), [scene]);
|
|
const presentationGroupRef = useRef<THREE.Group>(null);
|
|
const baseScaleRef = useRef<number>(1);
|
|
const centerRef = useRef<THREE.Vector3>(new THREE.Vector3());
|
|
|
|
// Calcula escala de aproximadamente 1 metro para caber na mesa (apenas UMA vez no carregamento)
|
|
useEffect(() => {
|
|
if (!clone) return;
|
|
|
|
// Reseta rotação e escala para obter o bounding box original limpo
|
|
const oldRot = clone.rotation.clone();
|
|
const oldScale = clone.scale.clone();
|
|
clone.rotation.set(0, 0, 0);
|
|
clone.scale.setScalar(1);
|
|
|
|
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;
|
|
baseScaleRef.current = scaleFactor;
|
|
|
|
const center = new THREE.Vector3();
|
|
box.getCenter(center);
|
|
centerRef.current.copy(center);
|
|
}
|
|
|
|
// Restaura
|
|
clone.rotation.copy(oldRot);
|
|
clone.scale.copy(oldScale);
|
|
}, [clone]);
|
|
|
|
// Aplica cor do modelo nos materiais do clone
|
|
useEffect(() => {
|
|
if (!clone || !model.color) return;
|
|
clone.traverse((child) => {
|
|
if (child instanceof THREE.Mesh) {
|
|
// Clone do material para evitar afetar cache global do R3F
|
|
if (Array.isArray(child.material)) {
|
|
child.material = child.material.map(m => m.clone());
|
|
} else {
|
|
child.material = child.material.clone();
|
|
}
|
|
|
|
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
|
materials.forEach((mat: THREE.Material) => {
|
|
if (mat instanceof THREE.MeshStandardMaterial) {
|
|
mat.color.set(model.color);
|
|
mat.needsUpdate = true;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}, [clone, model.color]);
|
|
|
|
// Aplica transformações de Rotação e Escala compartilhadas no grupo externo de apresentação
|
|
useFrame(() => {
|
|
if (presentationGroupRef.current && presentationState) {
|
|
const { rotation, scale } = presentationState;
|
|
if (rotation) {
|
|
presentationGroupRef.current.rotation.x = rotation[0];
|
|
presentationGroupRef.current.rotation.y = rotation[1];
|
|
presentationGroupRef.current.rotation.z = rotation[2];
|
|
}
|
|
if (scale) {
|
|
presentationGroupRef.current.scale.setScalar(baseScaleRef.current * 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);
|
|
|
|
// Ajusta a distância baseado no bounding box original e no nível do corte
|
|
const box = new THREE.Box3().setFromObject(clone);
|
|
const size = new THREE.Vector3();
|
|
box.getSize(size);
|
|
const minVal = sectionAxis === 'x' ? box.min.x : sectionAxis === 'y' ? box.min.y : box.min.z;
|
|
const maxVal = sectionAxis === 'x' ? box.max.x : sectionAxis === 'y' ? box.max.y : box.max.z;
|
|
const distance = minVal + (maxVal - minVal) * sectionLevel;
|
|
|
|
const plane = new THREE.Plane(normal, -distance);
|
|
|
|
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]);
|
|
|
|
// Translações, Rotações finas e Escala fina nativas da maquete vindas do Viewer
|
|
const fineTuning = model.fineTuning || { posX: 0, posY: 0, posZ: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 };
|
|
const rotXRad = (fineTuning.rotX * Math.PI) / 180;
|
|
const rotYRad = (fineTuning.rotY * Math.PI) / 180;
|
|
const rotZRad = (fineTuning.rotZ * Math.PI) / 180;
|
|
const s = fineTuning.scale ?? 1;
|
|
|
|
// Quaternion de calibração nativo da maquete
|
|
const calQuatArr = model.calibrationQuat;
|
|
const calQuat = useMemo(() => {
|
|
if (!calQuatArr) return new THREE.Quaternion();
|
|
return new THREE.Quaternion(calQuatArr[0], calQuatArr[1], calQuatArr[2], calQuatArr[3]);
|
|
}, [calQuatArr]);
|
|
|
|
return (
|
|
// Grupo Externo de Apresentação Compartilhada
|
|
<group ref={presentationGroupRef}>
|
|
{/* Grupo de Translação Fina */}
|
|
<group position={[fineTuning.posX, fineTuning.posY, fineTuning.posZ]}>
|
|
{/* Grupo de Rotação Fina e Escala Fina */}
|
|
<group rotation={[rotXRad, rotYRad, rotZRad]} scale={[s, s, s]}>
|
|
{/* Grupo de Calibração */}
|
|
<group quaternion={calQuat}>
|
|
{/* Grupo de Centralização do Bounding Box (para girar em torno do seu centro geométrico) */}
|
|
<group position={[-centerRef.current.x, -centerRef.current.y, -centerRef.current.z]}>
|
|
<primitive object={clone} />
|
|
</group>
|
|
</group>
|
|
</group>
|
|
</group>
|
|
</group>
|
|
);
|
|
}
|
|
|
|
// Cenário da Sala de Reunião Clara
|
|
function ThreeMeetingRoomScene({ currentActiveModel, presentationState, isFocusMode }: { currentActiveModel: SceneModel | null; presentationState: PresentationState; isFocusMode: boolean }) {
|
|
const tableGeom = useMemo(() => {
|
|
// Geometria da mesa em U / Retangular de cantos arredondados
|
|
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>
|
|
{/* Luzes da Sala / Foco */}
|
|
<ambientLight intensity={isFocusMode ? 0.7 : 0.8} />
|
|
<directionalLight position={isFocusMode ? [5, 5, 5] : [4, 6, 4]} intensity={1.5} castShadow={!isFocusMode} />
|
|
|
|
{isFocusMode && (
|
|
<color attach="background" args={['#090d16']} />
|
|
)}
|
|
|
|
{!isFocusMode && (
|
|
<>
|
|
{/* 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/Retangular com furo central (Madeira Mel Carvalho Clara) */}
|
|
<group position={[0, 0.8, 0]} rotation={[Math.PI / 2, 0, 0]}>
|
|
<mesh geometry={tableGeom} castShadow receiveShadow>
|
|
<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]} castShadow receiveShadow>
|
|
<cylinderGeometry args={[0.035, 0.035, 0.8]} />
|
|
<meshStandardMaterial color="#d4d4d8" metalness={0.9} roughness={0.1} />
|
|
</mesh>
|
|
))
|
|
)}
|
|
|
|
{/* Projetor de Holograma / Círculo centralizado desenhado na mesa */}
|
|
<mesh position={[0, 0.861, 0]} rotation={[-Math.PI / 2, 0, 0]}>
|
|
<ringGeometry args={[0.22, 0.25, 32]} />
|
|
<meshBasicMaterial color="#0ea5e9" transparent opacity={0.6} />
|
|
</mesh>
|
|
</>
|
|
)}
|
|
|
|
{/* Modelo 3D da Maquete Holográfica */}
|
|
<group position={isFocusMode ? [0, 0, 0] : [0, 1.23, 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>
|
|
|
|
{/* Renderizar as Cadeiras Físicas da mesa - Ocultadas no Modo Foco */}
|
|
{!isFocusMode && 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 - Ocultada no Modo Foco */}
|
|
{!isFocusMode && (
|
|
<>
|
|
<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 isWebGLSupported = useWebGLSupport();
|
|
const { roomId } = useParams<{ roomId?: string }>();
|
|
const navigate = useNavigate();
|
|
const models = useModelStore((s) => s.models);
|
|
const activeModelId = useModelStore((s) => s.activeModelId);
|
|
const activeModel = useMemo(() => {
|
|
return models.find((m) => m.id === activeModelId) || null;
|
|
}, [models, activeModelId]);
|
|
|
|
// Estados do Lobby
|
|
const [inRoom, setInRoom] = useState(false);
|
|
const [isConnecting, setIsConnecting] = useState(false);
|
|
const [isFocusMode, setIsFocusMode] = useState(false);
|
|
const [name, setName] = useState(() => localStorage.getItem('tsxr_meeting_user_name') || '');
|
|
const [cargo, setCargo] = useState(() => localStorage.getItem('tsxr_meeting_user_cargo') || '');
|
|
const [empresa, setEmpresa] = useState(() => localStorage.getItem('tsxr_meeting_user_empresa') || '');
|
|
const [isPresenter, setIsPresenter] = useState(() => {
|
|
const saved = localStorage.getItem('tsxr_meeting_user_is_presenter');
|
|
if (saved !== null) return saved === 'true';
|
|
return !roomId;
|
|
});
|
|
const [selectedAvatar, setSelectedAvatar] = useState(() => Number(localStorage.getItem('tsxr_meeting_user_avatar')) || 1);
|
|
const [selectedChair, setSelectedChair] = useState(() => localStorage.getItem('tsxr_meeting_user_chair') || '1');
|
|
const [inputRoomId, setInputRoomId] = useState(roomId || '');
|
|
|
|
// Estados da Sala (Conexão Supabase)
|
|
const [peers, setPeers] = useState<Record<string, PeerData>>({});
|
|
|
|
// Lista de avatares permitidos (IDs de 1 a 20). Por padrão, todos estão ativos.
|
|
const [allowedAvatars, setAllowedAvatars] = useState<number[]>(() => {
|
|
return Array.from({ length: 20 }, (_, i) => i + 1);
|
|
});
|
|
const [isAvatarConfigOpen, setIsAvatarConfigOpen] = useState(false);
|
|
|
|
const updateAllowedAvatars = async (newAllowed: number[]) => {
|
|
setAllowedAvatars(newAllowed);
|
|
if (isPresenter && channelRef.current) {
|
|
console.log("⚙️ [MeetingRoom] Atualizando metadados de Presence com avatares permitidos:", newAllowed);
|
|
await channelRef.current.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(),
|
|
allowedAvatars: newAllowed
|
|
});
|
|
}
|
|
};
|
|
|
|
const mergedPeers = useMemo(() => {
|
|
if (!inRoom) return peers;
|
|
|
|
// Se o usuário local já estiver no peers (via sync do Supabase), usamos o peers original
|
|
if (peers[selectedChair]) {
|
|
return peers;
|
|
}
|
|
|
|
// Caso contrário, injetamos o usuário local no dicionário temporário
|
|
return {
|
|
...peers,
|
|
[selectedChair]: {
|
|
presence_ref: 'local_user',
|
|
username: name,
|
|
avatarId: selectedAvatar,
|
|
chairId: selectedChair,
|
|
role: isPresenter ? 'presenter' : 'guest',
|
|
cargo: cargo || 'Apresentador',
|
|
empresa: empresa || 'SteelXR Corp',
|
|
joinedAt: new Date().toISOString()
|
|
} as unknown as PeerData
|
|
};
|
|
}, [peers, inRoom, selectedChair, name, selectedAvatar, isPresenter, cargo, empresa]);
|
|
|
|
const activeUsersCount = useMemo(() => Object.keys(mergedPeers).length, [mergedPeers]);
|
|
|
|
const [chatMessages, setChatMessages] = useState<{ id: string; sender: string; text: string; time: string }[]>([]);
|
|
const [newMessage, setNewMessage] = useState('');
|
|
const [lastChatSender, setLastChatSender] = useState<string | null>(null);
|
|
const [isMuted, setIsMuted] = useState(false);
|
|
const [isTalking, setIsTalking] = useState(false);
|
|
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 || inRoom || isConnecting) {
|
|
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();
|
|
console.log("👥 [Lobby Watcher Sync] Estado de presença bruto:", state);
|
|
const formattedPeers: Record<string, PeerData> = {};
|
|
let remoteAllowed: number[] | null = null;
|
|
|
|
Object.keys(state).forEach((key) => {
|
|
if (key.startsWith('lobby_watcher_')) return;
|
|
const presences = state[key] as unknown as PeerData[];
|
|
if (presences && presences.length > 0) {
|
|
presences.forEach((presence) => {
|
|
if (presence && presence.chairId && presence.username) {
|
|
formattedPeers[presence.chairId] = {
|
|
...presence,
|
|
avatarId: Number(presence.avatarId) || 1,
|
|
cargo: presence.cargo || 'Colaborador',
|
|
empresa: presence.empresa || 'SteelXR Corp',
|
|
role: presence.role || 'guest'
|
|
};
|
|
|
|
if (presence.role === 'presenter' && Array.isArray(presence.allowedAvatars)) {
|
|
remoteAllowed = presence.allowedAvatars;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
console.log("👥 [Lobby Watcher Sync] Peers formatados:", formattedPeers);
|
|
setPeers(formattedPeers);
|
|
|
|
if (remoteAllowed) {
|
|
console.log("⚙️ [Lobby Watcher Sync] Sincronizados avatares permitidos remotos:", remoteAllowed);
|
|
setAllowedAvatars(remoteAllowed);
|
|
}
|
|
})
|
|
.subscribe();
|
|
|
|
return () => {
|
|
console.log(`🔌 [Lobby Watcher] Desinscrevendo escuta passiva da sala: ${roomIdUpper}`);
|
|
tempChannel.unsubscribe();
|
|
supabase.removeChannel(tempChannel);
|
|
};
|
|
}, [inputRoomId, inRoom, isConnecting]);
|
|
|
|
// Envia as atualizações de apresentação via Broadcast para a sala inteira
|
|
const handlePresentationChange = (updatedFields: Partial<PresentationState>) => {
|
|
if (!isPresenter || !channelRef.current) return;
|
|
|
|
const store = useModelStore.getState();
|
|
const currentModel = store.models.find(m => m.id === store.activeModelId) || null;
|
|
|
|
const newState = {
|
|
...presentationState,
|
|
...updatedFields,
|
|
modelUrl: currentModel?.url,
|
|
modelFileName: currentModel?.fileName,
|
|
modelFileSize: currentModel?.fileSize
|
|
} 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;
|
|
}
|
|
|
|
// Salva no localStorage para auto-preenchimento conveniente
|
|
localStorage.setItem('tsxr_meeting_user_name', name);
|
|
localStorage.setItem('tsxr_meeting_user_cargo', cargo);
|
|
localStorage.setItem('tsxr_meeting_user_empresa', empresa);
|
|
localStorage.setItem('tsxr_meeting_user_avatar', String(selectedAvatar));
|
|
localStorage.setItem('tsxr_meeting_user_chair', selectedChair);
|
|
localStorage.setItem('tsxr_meeting_user_is_presenter', String(isPresenter));
|
|
|
|
// 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;
|
|
}
|
|
|
|
// Ativa o estado de conexão e aguarda 120ms para a escuta do lobby se desmontar totalmente
|
|
setIsConnecting(true);
|
|
await new Promise((resolve) => setTimeout(resolve, 120));
|
|
|
|
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 }
|
|
},
|
|
});
|
|
|
|
channelRef.current = channel;
|
|
|
|
// Sincroniza participantes online com tolerância a múltiplos formatos e falhas de conexão
|
|
channel
|
|
.on('presence', { event: 'sync' }, () => {
|
|
const state = channel.presenceState();
|
|
console.log("👥 [Presence Sync] Estado de presença bruto da reunião:", state);
|
|
const formattedPeers: Record<string, PeerData> = {};
|
|
let remoteAllowed: number[] | null = null;
|
|
|
|
Object.keys(state).forEach((key) => {
|
|
if (key.startsWith('lobby_watcher_')) return;
|
|
const presences = state[key] as unknown as PeerData[];
|
|
if (presences && presences.length > 0) {
|
|
presences.forEach((presence) => {
|
|
if (presence && presence.chairId && presence.username) {
|
|
formattedPeers[presence.chairId] = {
|
|
...presence,
|
|
avatarId: Number(presence.avatarId) || 1,
|
|
cargo: presence.cargo || 'Colaborador',
|
|
empresa: presence.empresa || 'SteelXR Corp',
|
|
role: presence.role || 'guest'
|
|
};
|
|
|
|
if (presence.role === 'presenter' && Array.isArray(presence.allowedAvatars)) {
|
|
remoteAllowed = presence.allowedAvatars;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
console.log("👥 [Presence Sync] Peers formatados:", formattedPeers);
|
|
setPeers(formattedPeers);
|
|
|
|
if (!isPresenter && remoteAllowed) {
|
|
console.log("⚙️ [Presence Sync] Sincronizando avatares permitidos remotos na sala conectada:", remoteAllowed);
|
|
setAllowedAvatars(remoteAllowed);
|
|
}
|
|
})
|
|
.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' }),
|
|
},
|
|
]);
|
|
setLastChatSender(payload.payload.sender);
|
|
});
|
|
|
|
// Escuta atualizações de Apresentação da Maquete via Broadcast (apenas para convidados)
|
|
channel.on('broadcast', { event: 'presentation_update' }, (payload) => {
|
|
if (!isPresenter) {
|
|
const data = payload.payload as PresentationState;
|
|
setPresentationState(data);
|
|
|
|
// Se o apresentador propagar um modelo remoto que o convidado não tem ativo, carrega
|
|
if (data.modelUrl && data.modelFileName) {
|
|
const store = useModelStore.getState();
|
|
const hasModel = store.models.some(m => m.fileName === data.modelFileName);
|
|
if (!hasModel) {
|
|
console.log(`📥 [MeetingRoom] Carregando modelo remoto do apresentador: ${data.modelFileName}`);
|
|
useModelStore.getState().addModel({
|
|
fileName: data.modelFileName,
|
|
fileSize: data.modelFileSize || 0,
|
|
url: data.modelUrl
|
|
});
|
|
} else {
|
|
const existing = store.models.find(m => m.fileName === data.modelFileName);
|
|
if (existing && store.activeModelId !== existing.id) {
|
|
useModelStore.getState().setActiveModel(existing.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// 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(),
|
|
allowedAvatars: allowedAvatars
|
|
});
|
|
|
|
// Se for o apresentador, propaga o estado inicial de apresentação (vinda do Viewer) para todos
|
|
if (isPresenter) {
|
|
const store = useModelStore.getState();
|
|
const currentModel = store.models.find(m => m.id === store.activeModelId) || null;
|
|
channel.send({
|
|
type: 'broadcast',
|
|
event: 'presentation_update',
|
|
payload: {
|
|
...presentationState,
|
|
modelUrl: currentModel?.url,
|
|
modelFileName: currentModel?.fileName,
|
|
modelFileSize: currentModel?.fileSize
|
|
}
|
|
});
|
|
}
|
|
|
|
setInRoom(true);
|
|
setIsConnecting(false);
|
|
toast.success(`Entrou na sala de reunião: ${finalRoomId}`);
|
|
navigate(`/meeting/${finalRoomId}`, { replace: true });
|
|
} else if (status === 'CHANNEL_ERROR') {
|
|
setIsConnecting(false);
|
|
toast.error('Erro ao conectar ao canal de reuniões.');
|
|
}
|
|
});
|
|
};
|
|
|
|
// Enviar Mensagem de Chat
|
|
const handleSendMessage = () => {
|
|
if (!newMessage.trim() || !channelRef.current) return;
|
|
const senderName = name.substring(0, 10);
|
|
channelRef.current.send({
|
|
type: 'broadcast',
|
|
event: 'chat_msg',
|
|
payload: {
|
|
sender: senderName,
|
|
text: newMessage.trim(),
|
|
},
|
|
});
|
|
setChatMessages((prev) => [
|
|
...prev,
|
|
{
|
|
id: Math.random().toString(),
|
|
sender: senderName,
|
|
text: newMessage.trim(),
|
|
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
|
},
|
|
]);
|
|
setLastChatSender(senderName);
|
|
setNewMessage('');
|
|
};
|
|
|
|
// Sair da Sala de Reunião
|
|
const handleLeaveRoom = async () => {
|
|
if (channelRef.current) {
|
|
await supabase.removeChannel(channelRef.current);
|
|
}
|
|
setInRoom(false);
|
|
setIsConnecting(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]);
|
|
|
|
// Lista de avatares disponíveis para este usuário escolher
|
|
const availableAvatars = useMemo(() => {
|
|
if (isPresenter) {
|
|
return AVATAR_PRESETS.map((p) => p.id);
|
|
}
|
|
// Para convidados, filtra apenas os IDs permitidos pelo apresentador
|
|
return AVATAR_PRESETS.map((p) => p.id).filter((id) => allowedAvatars.includes(id));
|
|
}, [isPresenter, allowedAvatars]);
|
|
|
|
// Se o selectedAvatar atual não estiver na lista de permitidos, selecionamos o primeiro disponível
|
|
useEffect(() => {
|
|
if (availableAvatars.length > 0 && !availableAvatars.includes(selectedAvatar)) {
|
|
setSelectedAvatar(availableAvatars[0]);
|
|
}
|
|
}, [availableAvatars, selectedAvatar]);
|
|
|
|
const nextAvatar = () => {
|
|
if (availableAvatars.length === 0) return;
|
|
const currentIndex = availableAvatars.indexOf(selectedAvatar);
|
|
const nextIndex = (currentIndex + 1) % availableAvatars.length;
|
|
setSelectedAvatar(availableAvatars[nextIndex]);
|
|
};
|
|
|
|
const prevAvatar = () => {
|
|
if (availableAvatars.length === 0) return;
|
|
const currentIndex = availableAvatars.indexOf(selectedAvatar);
|
|
const prevIndex = (currentIndex - 1 + availableAvatars.length) % availableAvatars.length;
|
|
setSelectedAvatar(availableAvatars[prevIndex]);
|
|
};
|
|
|
|
// --- 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">
|
|
{isWebGLSupported ? (
|
|
<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>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center h-full w-full bg-slate-950/80 p-4 text-center">
|
|
<ShieldAlert className="h-7 w-7 text-amber-500 animate-pulse mb-1" />
|
|
<span className="font-mono text-[9px] font-bold text-amber-500 uppercase tracking-wider">WebGL Desativado</span>
|
|
<span className="text-[8px] text-slate-400 mt-1 max-w-[180px] leading-relaxed">
|
|
Aceleração por hardware inativa. O avatar 3D não pôde ser pré-visualizado.
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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 w-full px-2">
|
|
<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>
|
|
|
|
{isPresenter && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setIsAvatarConfigOpen(true)}
|
|
className="mt-3 w-full font-mono text-[9px] uppercase tracking-wider gap-1 border-primary/40 hover:bg-primary/10 text-primary-foreground bg-primary/5 h-8"
|
|
>
|
|
<Settings className="h-3.5 w-3.5" />
|
|
Gerenciar Avatares ({allowedAvatars.length} ativos)
|
|
</Button>
|
|
)}
|
|
</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>
|
|
|
|
{/* Modal de Configuração de Avatares Permitidos (Apenas para o Apresentador) */}
|
|
{isAvatarConfigOpen && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
|
<div className="w-full max-w-3xl rounded-xl border border-primary/30 bg-slate-900/90 p-6 md:p-8 shadow-2xl backdrop-blur-md flex flex-col max-h-[85vh] animate-in zoom-in-95 duration-200">
|
|
<div className="flex justify-between items-start border-b border-slate-800 pb-4 mb-4">
|
|
<div>
|
|
<h3 className="text-lg font-bold text-primary font-mono uppercase tracking-wider">Avatares da Reunião</h3>
|
|
<p className="text-[10px] text-muted-foreground font-mono mt-0.5 uppercase tracking-wide">
|
|
Selecione entre 4 e 10 avatares que estarão disponíveis para a reunião
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-xs px-2 py-0.5 rounded-full bg-slate-800 border text-slate-300">
|
|
{allowedAvatars.length} Selecionados
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Grid dos Avatares para Seleção */}
|
|
<div className="flex-1 overflow-y-auto pr-1 py-1 grid grid-cols-2 sm:grid-cols-4 md:grid-cols-5 gap-3">
|
|
{AVATAR_PRESETS.map((preset) => {
|
|
const isSelected = allowedAvatars.includes(preset.id);
|
|
const isMale = preset.id <= 10;
|
|
|
|
return (
|
|
<div
|
|
key={preset.id}
|
|
onClick={() => {
|
|
if (isSelected) {
|
|
// Se desmarcar, verifica se fica com menos de 4
|
|
if (allowedAvatars.length <= 4) {
|
|
toast.warning('A reunião deve conter pelo menos 4 avatares disponíveis.');
|
|
return;
|
|
}
|
|
updateAllowedAvatars(allowedAvatars.filter(id => id !== preset.id));
|
|
} else {
|
|
// Se marcar, verifica se excede 10
|
|
if (allowedAvatars.length >= 10) {
|
|
toast.warning('Você pode selecionar no máximo 10 avatares.');
|
|
return;
|
|
}
|
|
updateAllowedAvatars([...allowedAvatars, preset.id]);
|
|
}
|
|
}}
|
|
className={`relative flex flex-col items-center justify-center p-3 rounded-lg border-2 cursor-pointer transition-all duration-200 hover:scale-105 select-none ${
|
|
isSelected
|
|
? 'border-primary bg-primary/10 shadow-[0_0_10px_rgba(59,130,246,0.3)]'
|
|
: 'border-slate-800 bg-slate-950/40 hover:border-slate-700 hover:bg-slate-950/70'
|
|
}`}
|
|
>
|
|
{/* Badge Sexo */}
|
|
<span className={`absolute top-1 right-1.5 font-mono text-[7px] px-1 rounded uppercase font-bold ${
|
|
isMale ? 'bg-blue-500/20 text-blue-400' : 'bg-pink-500/20 text-pink-400'
|
|
}`}>
|
|
{isMale ? 'M' : 'F'}
|
|
</span>
|
|
|
|
{/* Miniatura da Imagem Local */}
|
|
<div className="w-14 h-14 bg-slate-900/60 rounded-full border overflow-hidden flex items-center justify-center mb-2">
|
|
<img
|
|
src={preset.url}
|
|
alt={preset.name}
|
|
className="w-12 h-12 object-contain"
|
|
/>
|
|
</div>
|
|
|
|
<span className="font-mono text-[10px] font-bold text-slate-200">{preset.name}</span>
|
|
<span className="text-[8px] text-slate-400 text-center mt-0.5 line-clamp-1 leading-snug">{preset.desc}</span>
|
|
|
|
{/* Check Icon */}
|
|
{isSelected && (
|
|
<div className="absolute -bottom-1.5 -right-1 bg-primary text-primary-foreground rounded-full p-0.5 shadow-md">
|
|
<Check className="h-3 w-3" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Botão de Fechar / Salvar */}
|
|
<div className="border-t border-slate-800 pt-4 mt-4 flex justify-end gap-3">
|
|
<Button
|
|
type="button"
|
|
variant="default"
|
|
onClick={() => {
|
|
if (allowedAvatars.length < 4 || allowedAvatars.length > 10) {
|
|
toast.error('Selecione entre 4 e 10 avatares antes de continuar.');
|
|
return;
|
|
}
|
|
setIsAvatarConfigOpen(false);
|
|
toast.success('Configuração de avatares atualizada com sucesso!');
|
|
}}
|
|
className="font-mono text-xs glow-primary px-6 animate-pulse"
|
|
>
|
|
Salvar Escolhas
|
|
</Button>
|
|
</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">
|
|
{isWebGLSupported ? (
|
|
<Canvas camera={{ position: [0, 2.5, 2.8], fov: 45 }} shadows gl={{ localClippingEnabled: true }}>
|
|
<ThreeMeetingRoomScene currentActiveModel={activeModel} presentationState={presentationState} isFocusMode={isFocusMode} />
|
|
|
|
{/* Renderizar avatares de TODOS na sala - Ocultados se modo foco */}
|
|
{!isFocusMode && Object.keys(mergedPeers).map((chairKey) => {
|
|
const peer = mergedPeers[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 || 'local'}
|
|
avatarId={peer.avatarId}
|
|
username={isLocalUser ? `${peer.username} (Você)` : peer.username}
|
|
position={chairConf.pos}
|
|
rotation={chairConf.rot}
|
|
isTalking={isLocalUser ? isTalking : (isTalking && Math.random() > 0.5)}
|
|
isLastChatSender={peer.username === lastChatSender}
|
|
scale={4.8}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
<OrbitControls
|
|
makeDefault
|
|
enableDamping
|
|
dampingFactor={0.05}
|
|
minDistance={0.5}
|
|
maxDistance={8}
|
|
maxPolarAngle={Math.PI / 2 - 0.05}
|
|
target={isFocusMode ? [0, 0, 0] : [0, 0.95, 0]}
|
|
/>
|
|
</Canvas>
|
|
) : (
|
|
<div className="flex-1 h-full flex flex-col items-center justify-center p-8 bg-slate-900 border border-slate-800 rounded-xl m-4 relative overflow-hidden">
|
|
<div className="absolute inset-0 bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 opacity-95 z-0" />
|
|
<div className="relative z-10 max-w-md text-center space-y-4">
|
|
<div className="mx-auto w-16 h-16 rounded-full bg-amber-500/10 border border-amber-500/30 flex items-center justify-center animate-pulse">
|
|
<ShieldAlert className="h-8 w-8 text-amber-500" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<h3 className="text-xl font-bold tracking-tight text-slate-100 font-mono uppercase">WebGL Requerido</h3>
|
|
<p className="text-xs text-slate-400 leading-relaxed">
|
|
Aceleração por hardware (WebGL) está desativada ou não é suportada por seu navegador/dispositivo atual.
|
|
</p>
|
|
</div>
|
|
<div className="bg-slate-950/80 rounded-lg p-4 text-left border border-slate-800 text-[11px] font-mono text-slate-400 space-y-2">
|
|
<span className="text-amber-500 font-bold uppercase block text-[10px] tracking-wider mb-1">Como resolver no Chrome:</span>
|
|
<p>1. Vá em <code className="text-primary">chrome://settings/system</code></p>
|
|
<p>2. Ative <code className="text-primary">"Usar aceleração de hardware quando disponível"</code></p>
|
|
<p>3. Reinicie o navegador e recarregue esta página.</p>
|
|
</div>
|
|
<div className="text-[10px] text-muted-foreground italic">
|
|
* Nota: O chat lateral, lista de participantes e chamadas continuam funcionando normalmente.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 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'] as const).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>
|
|
|
|
{/* Botão de Apresentação no Viewer */}
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
className="w-full h-9 font-mono text-[10px] gap-1.5 bg-sky-500 hover:bg-sky-600 text-slate-950 font-bold glow-primary"
|
|
onClick={() => navigate(`/viewer?room=${roomCode}`)}
|
|
>
|
|
<Box className="h-3.5 w-3.5" /> Manipular Peça no Viewer
|
|
</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 & Modo Foco */}
|
|
<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>
|
|
|
|
<Button
|
|
variant={isFocusMode ? 'secondary' : 'outline'}
|
|
size="icon"
|
|
className={`pointer-events-auto h-10 w-10 rounded-xl shadow-2xl border ${
|
|
isFocusMode
|
|
? 'bg-sky-500 hover:bg-sky-600 text-slate-950 border-sky-400'
|
|
: 'bg-slate-900/90 text-white border-slate-700 hover:bg-slate-800'
|
|
}`}
|
|
onClick={() => setIsFocusMode(!isFocusMode)}
|
|
title={isFocusMode ? 'Voltar para Sala Virtual (Minimizar)' : 'Focar na Peça (Tela Cheia)'}
|
|
>
|
|
{isFocusMode ? <Minimize2 className="h-4.5 w-4.5" /> : <Maximize2 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(mergedPeers).map((chairKey) => {
|
|
const peer = mergedPeers[chairKey];
|
|
const isHost = peer.role === 'presenter';
|
|
const isMe = peer.chairId === selectedChair;
|
|
|
|
return (
|
|
<div key={peer.presence_ref || 'local'} 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>
|
|
);
|
|
}
|