🚀 Auto-deploy: melhoria no snap e medição AR em 25/05/2026 16:51:02

This commit is contained in:
2026-05-25 16:51:02 +00:00
parent 03411d9224
commit a08ab50917
21 changed files with 235 additions and 27 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+235 -27
View File
@@ -43,7 +43,8 @@ import { toast } from 'sonner';
import {
Users, MessageSquare, Mic, MicOff, Home, Send,
ArrowRight, ChevronLeft, ChevronRight, Share2, LogOut,
RotateCw, Award, ShieldAlert, Maximize2, Minimize2, Box
RotateCw, Award, ShieldAlert, Maximize2, Minimize2, Box,
Settings, Check
} from 'lucide-react';
// Interfaces tipadas para evitar any
@@ -75,6 +76,7 @@ interface PeerData {
cargo: string;
empresa: string;
presence_ref: string;
allowedAvatars?: number[];
}
// Detector nativo de suporte ao WebGL
@@ -112,18 +114,28 @@ const CHAIRS = [
];
const AVATAR_PRESETS: AvatarPreset[] = [
// Homens (5)
{ id: 1, name: 'Carlos', desc: 'Estilo corporativo masculino clássico', url: 'https://models.readyplayer.me/65851410d54020a5605d8f63.glb?bodyType=halfbody', color: '#ef4444' },
{ id: 2, name: 'Lucas', desc: 'Estilo moderno masculino com barba', url: 'https://models.readyplayer.me/6585144b248a3064be486a4f.glb?bodyType=halfbody', color: '#b45309' },
{ id: 3, name: 'Gabriel', desc: 'Jovem e sorridente com cabelo escuro', url: 'https://models.readyplayer.me/65851480f2d52f672d6199bc.glb?bodyType=halfbody', color: '#eab308' },
{ id: 4, name: 'Marcos', desc: 'Visual executivo com óculos de grau', url: 'https://models.readyplayer.me/658514b8d54020a5605d92df.glb?bodyType=halfbody', color: '#3b82f6' },
{ id: 5, name: 'Ricardo', desc: 'Design casual moderno e cabelos grisalhos', url: 'https://models.readyplayer.me/658514ec248a3064be486f0b.glb?bodyType=halfbody', color: '#f97316' },
// Mulheres (5)
{ id: 6, name: 'Juliana', desc: 'Visual feminino clássico executivo', url: 'https://models.readyplayer.me/65851336d54020a5605d820f.glb?bodyType=halfbody', color: '#10b981' },
{ id: 7, name: 'Fernanda', desc: 'Cabelo longo escuro e roupa corporativa', url: 'https://models.readyplayer.me/65851375248a3064be485d1d.glb?bodyType=halfbody', color: '#78350f' },
{ id: 8, name: 'Camila', desc: 'Estilo moderno e cabelos cacheados', url: 'https://models.readyplayer.me/658513a9f2d52f672d61937f.glb?bodyType=halfbody', color: '#1e293b' },
{ id: 9, name: 'Beatriz', desc: 'Cabelo ruivo e trajes executivos finos', url: 'https://models.readyplayer.me/658513dbf2d52f672d6195be.glb?bodyType=halfbody', color: '#a855f7' },
{ id: 10, name: 'Amanda', desc: 'Visual jovem loira e roupas elegantes', url: 'https://models.readyplayer.me/65851403248a3064be48651a.glb?bodyType=halfbody', color: '#ca8a04' },
// 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'];
@@ -136,13 +148,23 @@ const getAvatarBgClass = (avatarId: number): string => {
'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-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-yellow-600/20 text-yellow-500'
'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) % 10] || classes[0];
return classes[(avatarId - 1) % classes.length] || classes[0];
};
// --- Subcomponentes 3D ---
@@ -178,9 +200,9 @@ function MiiAvatarBackup({
}, [isLastChatSender]);
const avatarUrl = useMemo(() => {
const seed = `${username || 'user'}_${avatarId}`;
return `https://api.dicebear.com/9.x/toon-head/png?seed=${encodeURIComponent(seed)}`;
}, [username, avatarId]);
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) {
@@ -261,7 +283,6 @@ function MiiAvatarBackup({
src={avatarUrl}
alt={username}
className="w-full h-full object-contain drop-shadow-md"
style={{ minWidth: '64px', minHeight: '64px' }}
onError={(e) => {
(e.currentTarget as HTMLImageElement).src = `https://ui-avatars.com/api/?name=${encodeURIComponent(username || 'U')}&background=475569&color=fff&size=128&rounded=true`;
}}
@@ -312,6 +333,13 @@ function MiiAvatar(props: {
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} />}>
@@ -832,6 +860,29 @@ export default function MeetingRoom() {
// 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;
@@ -941,6 +992,7 @@ export default function MeetingRoom() {
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;
@@ -955,6 +1007,10 @@ export default function MeetingRoom() {
empresa: presence.empresa || 'SteelXR Corp',
role: presence.role || 'guest'
};
if (presence.role === 'presenter' && Array.isArray(presence.allowedAvatars)) {
remoteAllowed = presence.allowedAvatars;
}
}
});
}
@@ -962,6 +1018,11 @@ export default function MeetingRoom() {
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();
@@ -1054,7 +1115,7 @@ export default function MeetingRoom() {
const state = channel.presenceState();
console.log("👥 [Presence Sync] Estado de presença bruto da reunião:", state);
const formattedPeers: Record<string, PeerData> = {};
let count = 0;
let remoteAllowed: number[] | null = null;
Object.keys(state).forEach((key) => {
if (key.startsWith('lobby_watcher_')) return;
@@ -1069,13 +1130,21 @@ export default function MeetingRoom() {
empresa: presence.empresa || 'SteelXR Corp',
role: presence.role || 'guest'
};
count++;
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;
@@ -1139,6 +1208,7 @@ export default function MeetingRoom() {
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
@@ -1214,11 +1284,34 @@ export default function MeetingRoom() {
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 = () => {
setSelectedAvatar((prev) => (prev === 10 ? 1 : prev + 1));
if (availableAvatars.length === 0) return;
const currentIndex = availableAvatars.indexOf(selectedAvatar);
const nextIndex = (currentIndex + 1) % availableAvatars.length;
setSelectedAvatar(availableAvatars[nextIndex]);
};
const prevAvatar = () => {
setSelectedAvatar((prev) => (prev === 1 ? 10 : prev - 1));
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 ---
@@ -1289,11 +1382,24 @@ export default function MeetingRoom() {
</Button>
</div>
<div className="mt-3 text-center">
<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 */}
@@ -1415,6 +1521,108 @@ export default function MeetingRoom() {
</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>
);
}