fix: update dependencies and improve chat component structure
- Updated `lucide-svelte` dependency to version 0.552.0 across multiple files for consistency. - Refactored chat components to enhance structure and readability, including adjustments to the Sidebar, ChatList, and MessageInput components. - Improved notification handling in chat components to ensure better user experience and responsiveness. - Added type safety enhancements in various components to ensure better integration with backend data models.
This commit is contained in:
@@ -44,6 +44,7 @@
|
||||
"emoji-picker-element": "^1.27.0",
|
||||
"jspdf": "^3.0.3",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"lucide-svelte": "^0.552.0",
|
||||
"papaparse": "^5.4.1",
|
||||
"svelte-sonner": "^1.0.5",
|
||||
"zod": "^4.1.12"
|
||||
|
||||
@@ -230,7 +230,7 @@
|
||||
{#if authStore.autenticado}
|
||||
<!-- Sino de notificações no canto superior direito -->
|
||||
<div class="relative">
|
||||
<NotificationBell />
|
||||
<NotificationBell />
|
||||
</div>
|
||||
|
||||
<div class="hidden lg:flex flex-col items-end mr-2">
|
||||
@@ -261,15 +261,15 @@
|
||||
/>
|
||||
{:else}
|
||||
<!-- Ícone de usuário moderno (fallback) -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="w-7 h-7 text-white relative z-10 group-hover:scale-110 transition-transform duration-300"
|
||||
style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3));"
|
||||
>
|
||||
<path fill-rule="evenodd" d="M18.685 19.097A9.723 9.723 0 0021.75 12c0-5.385-4.365-9.75-9.75-9.75S2.25 6.615 2.25 12a9.723 9.723 0 003.065 7.097A9.716 9.716 0 0012 21.75a9.716 9.716 0 006.685-2.653zm-12.54-1.285A7.486 7.486 0 0112 15a7.486 7.486 0 015.855 2.812A8.224 8.224 0 0112 20.25a8.224 8.224 0 01-5.855-2.438zM15.75 9a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="w-7 h-7 text-white relative z-10 group-hover:scale-110 transition-transform duration-300"
|
||||
style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3));"
|
||||
>
|
||||
<path fill-rule="evenodd" d="M18.685 19.097A9.723 9.723 0 0021.75 12c0-5.385-4.365-9.75-9.75-9.75S2.25 6.615 2.25 12a9.723 9.723 0 003.065 7.097A9.716 9.716 0 0012 21.75a9.716 9.716 0 006.685-2.653zm-12.54-1.285A7.486 7.486 0 0112 15a7.486 7.486 0 015.855 2.812A8.224 8.224 0 0112 20.25a8.224 8.224 0 01-5.855-2.438zM15.75 9a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
<!-- Badge de status online -->
|
||||
@@ -329,30 +329,30 @@
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<!-- Footer -->
|
||||
<footer class="footer footer-center bg-gradient-to-r from-primary/30 via-primary/20 to-primary/30 backdrop-blur-sm text-base-content p-6 border-t-2 border-primary/20 shadow-inner flex-shrink-0">
|
||||
<div class="grid grid-flow-col gap-6 text-sm font-medium">
|
||||
<button type="button" class="link link-hover hover:text-primary transition-colors" onclick={() => openAboutModal()}>Sobre</button>
|
||||
<span class="text-base-content/30">•</span>
|
||||
<a href="/" class="link link-hover hover:text-primary transition-colors">Contato</a>
|
||||
<span class="text-base-content/30">•</span>
|
||||
<a href="/" class="link link-hover hover:text-primary transition-colors">Suporte</a>
|
||||
<span class="text-base-content/30">•</span>
|
||||
<a href="/" class="link link-hover hover:text-primary transition-colors">Privacidade</a>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-2">
|
||||
<div class="avatar">
|
||||
<div class="w-10 rounded-lg bg-white p-1.5 shadow-md">
|
||||
<img src={logo} alt="Logo" class="w-full h-full object-contain" />
|
||||
<div class="grid grid-flow-col gap-6 text-sm font-medium">
|
||||
<button type="button" class="link link-hover hover:text-primary transition-colors" onclick={() => openAboutModal()}>Sobre</button>
|
||||
<span class="text-base-content/30">•</span>
|
||||
<a href="/" class="link link-hover hover:text-primary transition-colors">Contato</a>
|
||||
<span class="text-base-content/30">•</span>
|
||||
<a href="/" class="link link-hover hover:text-primary transition-colors">Suporte</a>
|
||||
<span class="text-base-content/30">•</span>
|
||||
<a href="/" class="link link-hover hover:text-primary transition-colors">Privacidade</a>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-2">
|
||||
<div class="avatar">
|
||||
<div class="w-10 rounded-lg bg-white p-1.5 shadow-md">
|
||||
<img src={logo} alt="Logo" class="w-full h-full object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<p class="text-xs font-bold text-primary">Governo do Estado de Pernambuco</p>
|
||||
<p class="text-xs text-base-content/70">Secretaria de Esportes</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<p class="text-xs font-bold text-primary">Governo do Estado de Pernambuco</p>
|
||||
<p class="text-xs text-base-content/70">Secretaria de Esportes</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/60 mt-2">© {new Date().getFullYear()} - Todos os direitos reservados</p>
|
||||
</footer>
|
||||
<p class="text-xs text-base-content/60 mt-2">© {new Date().getFullYear()} - Todos os direitos reservados</p>
|
||||
</footer>
|
||||
</div>
|
||||
<div class="drawer-side z-40 fixed" style="margin-top: 96px;">
|
||||
<label for="my-drawer-3" aria-label="close sidebar" class="drawer-overlay"
|
||||
|
||||
@@ -247,14 +247,14 @@
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if activeTab === "usuarios"}
|
||||
<!-- Lista de usuários -->
|
||||
{#if usuarios?.data && usuariosFiltrados.length > 0}
|
||||
{#each usuariosFiltrados as usuario (usuario._id)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-3 hover:bg-base-200 border-b border-base-300 transition-colors flex items-center gap-3 {processando ? 'opacity-50 cursor-wait' : 'cursor-pointer'}"
|
||||
onclick={() => handleClickUsuario(usuario)}
|
||||
disabled={processando}
|
||||
>
|
||||
{#if usuarios?.data && usuariosFiltrados.length > 0}
|
||||
{#each usuariosFiltrados as usuario (usuario._id)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-3 hover:bg-base-200 border-b border-base-300 transition-colors flex items-center gap-3 {processando ? 'opacity-50 cursor-wait' : 'cursor-pointer'}"
|
||||
onclick={() => handleClickUsuario(usuario)}
|
||||
disabled={processando}
|
||||
>
|
||||
<!-- Ícone de mensagem -->
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 hover:scale-110"
|
||||
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%); border: 1px solid rgba(102, 126, 234, 0.2);">
|
||||
@@ -273,67 +273,67 @@
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<UserAvatar
|
||||
avatar={usuario.avatar}
|
||||
fotoPerfilUrl={usuario.fotoPerfilUrl}
|
||||
nome={usuario.nome}
|
||||
size="md"
|
||||
/>
|
||||
<!-- Status badge -->
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<UserStatusBadge status={usuario.statusPresenca || "offline"} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<p class="font-semibold text-base-content truncate">
|
||||
{usuario.nome}
|
||||
</p>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full {
|
||||
usuario.statusPresenca === 'online' ? 'bg-success/20 text-success' :
|
||||
usuario.statusPresenca === 'ausente' ? 'bg-warning/20 text-warning' :
|
||||
usuario.statusPresenca === 'em_reuniao' ? 'bg-error/20 text-error' :
|
||||
'bg-base-300 text-base-content/50'
|
||||
}">
|
||||
{getStatusLabel(usuario.statusPresenca)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm text-base-content/70 truncate">
|
||||
{usuario.statusMensagem || usuario.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{:else if !usuarios?.data}
|
||||
<!-- Loading -->
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Nenhum usuário encontrado -->
|
||||
<div class="flex flex-col items-center justify-center h-full text-center px-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-16 h-16 text-base-content/30 mb-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"
|
||||
<!-- Avatar -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<UserAvatar
|
||||
avatar={usuario.avatar}
|
||||
fotoPerfilUrl={usuario.fotoPerfilUrl}
|
||||
nome={usuario.nome}
|
||||
size="md"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-base-content/70">Nenhum usuário encontrado</p>
|
||||
</div>
|
||||
<!-- Status badge -->
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<UserStatusBadge status={usuario.statusPresenca || "offline"} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<p class="font-semibold text-base-content truncate">
|
||||
{usuario.nome}
|
||||
</p>
|
||||
<span class="text-xs px-2 py-0.5 rounded-full {
|
||||
usuario.statusPresenca === 'online' ? 'bg-success/20 text-success' :
|
||||
usuario.statusPresenca === 'ausente' ? 'bg-warning/20 text-warning' :
|
||||
usuario.statusPresenca === 'em_reuniao' ? 'bg-error/20 text-error' :
|
||||
'bg-base-300 text-base-content/50'
|
||||
}">
|
||||
{getStatusLabel(usuario.statusPresenca)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm text-base-content/70 truncate">
|
||||
{usuario.statusMensagem || usuario.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{:else if !usuarios?.data}
|
||||
<!-- Loading -->
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Nenhum usuário encontrado -->
|
||||
<div class="flex flex-col items-center justify-center h-full text-center px-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-16 h-16 text-base-content/30 mb-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-base-content/70">Nenhum usuário encontrado</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Lista de conversas (grupos e salas) -->
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
} from "$lib/stores/chatStore";
|
||||
import { useQuery } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import ChatList from "./ChatList.svelte";
|
||||
import ChatWindow from "./ChatWindow.svelte";
|
||||
import { authStore } from "$lib/stores/auth.svelte";
|
||||
@@ -156,18 +157,70 @@
|
||||
activeConversation = $conversaAtiva;
|
||||
});
|
||||
|
||||
// Tipos para conversas
|
||||
type ConversaComTimestamp = {
|
||||
_id: string;
|
||||
ultimaMensagemTimestamp?: number;
|
||||
ultimaMensagemRemetenteId?: string; // ID do remetente da última mensagem
|
||||
ultimaMensagem?: string;
|
||||
nome?: string;
|
||||
outroUsuario?: { nome: string };
|
||||
};
|
||||
|
||||
// Detectar novas mensagens globalmente (mesmo quando chat está fechado/minimizado)
|
||||
const todasConversas = useQuery(api.chat.listarConversas, {});
|
||||
let ultimoTimestampGlobal = $state<number>(0);
|
||||
let mensagensNotificadasGlobal = $state<Set<string>>(new Set());
|
||||
let showGlobalNotificationPopup = $state(false);
|
||||
let globalNotificationMessage = $state<{ remetente: string; conteudo: string; conversaId: string } | null>(null);
|
||||
let globalNotificationTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Carregar mensagens já notificadas do localStorage ao montar
|
||||
let mensagensCarregadasGlobal = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (typeof window !== 'undefined' && !mensagensCarregadasGlobal) {
|
||||
const saved = localStorage.getItem('chat-mensagens-notificadas-global');
|
||||
if (saved) {
|
||||
try {
|
||||
const ids = JSON.parse(saved) as string[];
|
||||
mensagensNotificadasGlobal = new Set(ids);
|
||||
} catch {
|
||||
mensagensNotificadasGlobal = new Set();
|
||||
}
|
||||
}
|
||||
mensagensCarregadasGlobal = true;
|
||||
|
||||
// Marcar todas as mensagens atuais como já visualizadas (não tocar beep ao abrir)
|
||||
if (todasConversas?.data) {
|
||||
const conversas = todasConversas.data as ConversaComTimestamp[];
|
||||
conversas.forEach((conv) => {
|
||||
if (conv.ultimaMensagemTimestamp) {
|
||||
const mensagemId = `${conv._id}-${conv.ultimaMensagemTimestamp}`;
|
||||
mensagensNotificadasGlobal.add(mensagemId);
|
||||
}
|
||||
});
|
||||
salvarMensagensNotificadasGlobal();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Salvar mensagens notificadas no localStorage
|
||||
function salvarMensagensNotificadasGlobal() {
|
||||
if (typeof window !== 'undefined') {
|
||||
const ids = Array.from(mensagensNotificadasGlobal);
|
||||
// Limitar a 1000 IDs para não encher o localStorage
|
||||
const idsLimitados = ids.slice(-1000);
|
||||
localStorage.setItem('chat-mensagens-notificadas-global', JSON.stringify(idsLimitados));
|
||||
}
|
||||
}
|
||||
|
||||
// Função para tocar som de notificação
|
||||
function tocarSomNotificacaoGlobal() {
|
||||
try {
|
||||
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
|
||||
const audioContext = new AudioContext();
|
||||
const AudioContextClass = window.AudioContext || (window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioContextClass) return;
|
||||
|
||||
const audioContext = new AudioContextClass();
|
||||
if (audioContext.state === 'suspended') {
|
||||
audioContext.resume().then(() => {
|
||||
const oscillator = audioContext.createOscillator();
|
||||
@@ -200,32 +253,44 @@
|
||||
|
||||
$effect(() => {
|
||||
if (todasConversas?.data && authStore.usuario?._id) {
|
||||
// Encontrar o maior timestamp de todas as conversas
|
||||
let maiorTimestamp = 0;
|
||||
let conversaComNovaMensagem: any = null;
|
||||
const conversas = todasConversas.data as ConversaComTimestamp[];
|
||||
|
||||
todasConversas.data.forEach((conv: any) => {
|
||||
if (conv.ultimaMensagemTimestamp && conv.ultimaMensagemTimestamp > maiorTimestamp) {
|
||||
maiorTimestamp = conv.ultimaMensagemTimestamp;
|
||||
conversaComNovaMensagem = conv;
|
||||
// Encontrar conversas com novas mensagens
|
||||
const meuId = String(authStore.usuario._id);
|
||||
|
||||
conversas.forEach((conv) => {
|
||||
if (!conv.ultimaMensagemTimestamp) return;
|
||||
|
||||
// Verificar se a última mensagem foi enviada pelo usuário atual
|
||||
const remetenteIdStr = conv.ultimaMensagemRemetenteId ? String(conv.ultimaMensagemRemetenteId) : null;
|
||||
if (remetenteIdStr && remetenteIdStr === meuId) {
|
||||
// Mensagem enviada pelo próprio usuário - não tocar beep nem mostrar notificação
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Se há nova mensagem (timestamp maior) e não estamos vendo essa conversa, tocar som e mostrar popup
|
||||
if (maiorTimestamp > ultimoTimestampGlobal && conversaComNovaMensagem) {
|
||||
// Criar ID único para esta mensagem: conversaId-timestamp
|
||||
const mensagemId = `${conv._id}-${conv.ultimaMensagemTimestamp}`;
|
||||
|
||||
// Verificar se já foi notificada
|
||||
if (mensagensNotificadasGlobal.has(mensagemId)) return;
|
||||
|
||||
const conversaAtivaId = activeConversation ? String(activeConversation) : null;
|
||||
const conversaIdStr = String(conversaComNovaMensagem._id);
|
||||
const conversaIdStr = String(conv._id);
|
||||
|
||||
// Só mostrar notificação se não estamos vendo essa conversa
|
||||
if (!isOpen || conversaAtivaId !== conversaIdStr) {
|
||||
// Tocar som de notificação
|
||||
// Marcar como notificada antes de tocar som (evita duplicação)
|
||||
mensagensNotificadasGlobal.add(mensagemId);
|
||||
salvarMensagensNotificadasGlobal();
|
||||
|
||||
// Tocar som de notificação (apenas uma vez)
|
||||
tocarSomNotificacaoGlobal();
|
||||
|
||||
// Mostrar popup de notificação
|
||||
globalNotificationMessage = {
|
||||
remetente: conversaComNovaMensagem.outroUsuario?.nome || conversaComNovaMensagem.nome || "Usuário",
|
||||
conteudo: conversaComNovaMensagem.ultimaMensagem || "",
|
||||
conversaId: conversaComNovaMensagem._id
|
||||
remetente: conv.outroUsuario?.nome || conv.nome || "Usuário",
|
||||
conteudo: conv.ultimaMensagem || "",
|
||||
conversaId: conv._id
|
||||
};
|
||||
showGlobalNotificationPopup = true;
|
||||
|
||||
@@ -238,9 +303,7 @@
|
||||
globalNotificationMessage = null;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
ultimoTimestampGlobal = maiorTimestamp;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -676,7 +739,7 @@
|
||||
}
|
||||
// Abrir chat e conversa ao clicar
|
||||
abrirChat();
|
||||
abrirConversa(globalNotificationMessage.conversaId as any);
|
||||
abrirConversa(globalNotificationMessage.conversaId as Id<"conversas">);
|
||||
}}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import SalaReuniaoManager from "./SalaReuniaoManager.svelte";
|
||||
import { getAvatarUrl } from "$lib/utils/avatarGenerator";
|
||||
import { authStore } from "$lib/stores/auth.svelte";
|
||||
import { Bell, X } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
conversaId: string;
|
||||
@@ -122,8 +123,8 @@
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-6 h-6 text-primary"
|
||||
>
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
@@ -391,21 +392,19 @@
|
||||
|
||||
<!-- Modal de Enviar Notificação -->
|
||||
{#if showNotificacaoModal && conversa()?.tipo === "sala_reuniao" && isAdmin?.data}
|
||||
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50" onclick={() => (showNotificacaoModal = false)}>
|
||||
<div
|
||||
class="bg-base-100 rounded-xl shadow-2xl w-full max-w-md m-4"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<dialog class="modal modal-open" onclick={(e) => e.target === e.currentTarget && (showNotificacaoModal = false)}>
|
||||
<div class="modal-box max-w-md" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300">
|
||||
<h2 class="text-xl font-semibold">Enviar Notificação</h2>
|
||||
<h2 class="text-xl font-semibold flex items-center gap-2">
|
||||
<Bell class="w-5 h-5 text-primary" />
|
||||
Enviar Notificação
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle"
|
||||
onclick={() => (showNotificacaoModal = false)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
@@ -474,6 +473,9 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" onclick={() => (showNotificacaoModal = false)}>fechar</button>
|
||||
</form>
|
||||
</dialog>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -9,6 +9,20 @@
|
||||
conversaId: Id<"conversas">;
|
||||
}
|
||||
|
||||
type ParticipanteInfo = {
|
||||
_id: Id<"usuarios">;
|
||||
nome: string;
|
||||
email?: string;
|
||||
fotoPerfilUrl?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
type ConversaComParticipantes = {
|
||||
_id: Id<"conversas">;
|
||||
tipo: "individual" | "grupo" | "sala_reuniao";
|
||||
participantesInfo?: ParticipanteInfo[];
|
||||
};
|
||||
|
||||
let { conversaId }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
@@ -43,25 +57,25 @@
|
||||
}
|
||||
|
||||
// Obter conversa atual
|
||||
const conversa = $derived(() => {
|
||||
const conversa = $derived((): ConversaComParticipantes | null => {
|
||||
if (!conversas?.data) return null;
|
||||
return conversas.data.find((c: any) => c._id === conversaId);
|
||||
return (conversas.data as ConversaComParticipantes[]).find((c) => c._id === conversaId) || null;
|
||||
});
|
||||
|
||||
// Obter participantes para menções (apenas grupos e salas)
|
||||
const participantesParaMencoes = $derived(() => {
|
||||
const participantesParaMencoes = $derived((): ParticipanteInfo[] => {
|
||||
const c = conversa();
|
||||
if (!c || (c.tipo !== "grupo" && c.tipo !== "sala_reuniao")) return [];
|
||||
return c.participantesInfo || [];
|
||||
});
|
||||
|
||||
// Filtrar participantes para dropdown de menções
|
||||
const participantesFiltrados = $derived(() => {
|
||||
const participantesFiltrados = $derived((): ParticipanteInfo[] => {
|
||||
if (!mentionQuery.trim()) return participantesParaMencoes().slice(0, 5);
|
||||
const query = mentionQuery.toLowerCase();
|
||||
return participantesParaMencoes().filter((p: any) =>
|
||||
return participantesParaMencoes().filter((p) =>
|
||||
p.nome?.toLowerCase().includes(query) ||
|
||||
p.email?.toLowerCase().includes(query)
|
||||
(p.email && p.email.toLowerCase().includes(query))
|
||||
).slice(0, 5);
|
||||
});
|
||||
|
||||
@@ -102,7 +116,7 @@
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function inserirMencao(participante: any) {
|
||||
function inserirMencao(participante: ParticipanteInfo) {
|
||||
const nome = participante.nome.split(' ')[0]; // Usar primeiro nome
|
||||
const antes = mensagem.substring(0, mentionStartPos);
|
||||
const depois = mensagem.substring(textarea.selectionStart || mensagem.length);
|
||||
@@ -128,7 +142,7 @@
|
||||
let match;
|
||||
while ((match = mentionRegex.exec(texto)) !== null) {
|
||||
const nomeMencionado = match[1];
|
||||
const participante = participantesParaMencoes().find((p: any) =>
|
||||
const participante = participantesParaMencoes().find((p) =>
|
||||
p.nome.split(' ')[0].toLowerCase() === nomeMencionado.toLowerCase()
|
||||
);
|
||||
if (participante) {
|
||||
@@ -175,13 +189,19 @@
|
||||
mensagemRespondendo = null;
|
||||
}
|
||||
|
||||
type MensagemComRemetente = {
|
||||
_id: Id<"mensagens">;
|
||||
conteudo: string;
|
||||
remetente?: { nome: string } | null;
|
||||
};
|
||||
|
||||
// Escutar evento de resposta
|
||||
onMount(() => {
|
||||
const handler = (e: Event) => {
|
||||
const customEvent = e as CustomEvent<{ mensagemId: Id<"mensagens"> }>;
|
||||
// Buscar informações da mensagem para exibir preview
|
||||
client.query(api.chat.obterMensagens, { conversaId, limit: 100 }).then((mensagens) => {
|
||||
const msg = mensagens.find((m: any) => m._id === customEvent.detail.mensagemId);
|
||||
const msg = (mensagens as MensagemComRemetente[]).find((m) => m._id === customEvent.detail.mensagemId);
|
||||
if (msg) {
|
||||
mensagemRespondendo = {
|
||||
id: msg._id,
|
||||
@@ -254,11 +274,11 @@
|
||||
const { storageId } = await result.json();
|
||||
|
||||
// 3. Enviar mensagem com o arquivo
|
||||
const tipo = file.type.startsWith("image/") ? "imagem" : "arquivo";
|
||||
const tipo: "imagem" | "arquivo" = file.type.startsWith("image/") ? "imagem" : "arquivo";
|
||||
await client.mutation(api.chat.enviarMensagem, {
|
||||
conversaId,
|
||||
conteudo: tipo === "imagem" ? "" : file.name,
|
||||
tipo: tipo as any,
|
||||
tipo,
|
||||
arquivoId: storageId,
|
||||
arquivoNome: file.name,
|
||||
arquivoTamanho: file.size,
|
||||
|
||||
@@ -21,14 +21,49 @@
|
||||
let messagesContainer: HTMLDivElement;
|
||||
let shouldScrollToBottom = true;
|
||||
let lastMessageCount = 0;
|
||||
let lastMessageId = $state<string | null>(null);
|
||||
let mensagensNotificadas = $state<Set<string>>(new Set());
|
||||
let showNotificationPopup = $state(false);
|
||||
let notificationMessage = $state<{ remetente: string; conteudo: string } | null>(null);
|
||||
let notificationTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let mensagensCarregadas = $state(false);
|
||||
|
||||
// Obter ID do usuário atual - usar $state para garantir reatividade
|
||||
let usuarioAtualId = $state<string | null>(null);
|
||||
|
||||
// Carregar mensagens já notificadas do localStorage ao montar
|
||||
$effect(() => {
|
||||
if (typeof window !== 'undefined' && !mensagensCarregadas) {
|
||||
const saved = localStorage.getItem('chat-mensagens-notificadas');
|
||||
if (saved) {
|
||||
try {
|
||||
const ids = JSON.parse(saved) as string[];
|
||||
mensagensNotificadas = new Set(ids);
|
||||
} catch {
|
||||
mensagensNotificadas = new Set();
|
||||
}
|
||||
}
|
||||
mensagensCarregadas = true;
|
||||
|
||||
// Marcar todas as mensagens atuais como já visualizadas (não tocar beep ao abrir)
|
||||
if (mensagens?.data && mensagens.data.length > 0) {
|
||||
mensagens.data.forEach((msg) => {
|
||||
mensagensNotificadas.add(String(msg._id));
|
||||
});
|
||||
salvarMensagensNotificadas();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Salvar mensagens notificadas no localStorage
|
||||
function salvarMensagensNotificadas() {
|
||||
if (typeof window !== 'undefined') {
|
||||
const ids = Array.from(mensagensNotificadas);
|
||||
// Limitar a 1000 IDs para não encher o localStorage
|
||||
const idsLimitados = ids.slice(-1000);
|
||||
localStorage.setItem('chat-mensagens-notificadas', JSON.stringify(idsLimitados));
|
||||
}
|
||||
}
|
||||
|
||||
// Atualizar usuarioAtualId sempre que authStore.usuario mudar
|
||||
$effect(() => {
|
||||
const usuario = authStore.usuario;
|
||||
@@ -44,7 +79,9 @@
|
||||
function tocarSomNotificacao() {
|
||||
try {
|
||||
// Usar AudioContext (requer interação do usuário para iniciar)
|
||||
const AudioContext = window.AudioContext || (window as any).webkitAudioContext;
|
||||
const AudioContextClass = window.AudioContext || (window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioContextClass) return;
|
||||
|
||||
let audioContext: AudioContext | null = null;
|
||||
|
||||
try {
|
||||
@@ -106,13 +143,18 @@
|
||||
// Detectar nova mensagem de outro usuário
|
||||
if (isNewMessage && mensagens.data.length > 0 && usuarioAtualId) {
|
||||
const ultimaMensagem = mensagens.data[mensagens.data.length - 1];
|
||||
const mensagemId = String(ultimaMensagem._id);
|
||||
const remetenteIdStr = ultimaMensagem.remetenteId
|
||||
? String(ultimaMensagem.remetenteId).trim()
|
||||
: (ultimaMensagem.remetente?._id ? String(ultimaMensagem.remetente._id).trim() : null);
|
||||
|
||||
// Se é uma nova mensagem de outro usuário (não minha)
|
||||
if (remetenteIdStr && remetenteIdStr !== usuarioAtualId && ultimaMensagem._id !== lastMessageId) {
|
||||
// Tocar som de notificação
|
||||
// Se é uma nova mensagem de outro usuário (não minha) E ainda não foi notificada
|
||||
if (remetenteIdStr && remetenteIdStr !== usuarioAtualId && !mensagensNotificadas.has(mensagemId)) {
|
||||
// Marcar como notificada antes de tocar som (evita duplicação)
|
||||
mensagensNotificadas.add(mensagemId);
|
||||
salvarMensagensNotificadas();
|
||||
|
||||
// Tocar som de notificação (apenas uma vez)
|
||||
tocarSomNotificacao();
|
||||
|
||||
// Mostrar popup de notificação
|
||||
@@ -130,8 +172,6 @@
|
||||
showNotificationPopup = false;
|
||||
notificationMessage = null;
|
||||
}, 5000);
|
||||
|
||||
lastMessageId = ultimaMensagem._id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,11 +351,11 @@
|
||||
alert(resultado.erro || "Erro ao deletar mensagem");
|
||||
}
|
||||
} else {
|
||||
await client.mutation(api.chat.deletarMensagem, {
|
||||
mensagemId,
|
||||
});
|
||||
await client.mutation(api.chat.deletarMensagem, {
|
||||
mensagemId,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error("Erro ao deletar mensagem:", error);
|
||||
alert(error.message || "Erro ao deletar mensagem");
|
||||
}
|
||||
@@ -548,20 +588,20 @@
|
||||
<div class="flex gap-1">
|
||||
{#if isMinha}
|
||||
<!-- Ações para minhas próprias mensagens -->
|
||||
<button
|
||||
class="text-xs text-base-content/50 hover:text-primary transition-colors"
|
||||
onclick={() => editarMensagem(mensagem)}
|
||||
title="Editar mensagem"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
class="text-xs text-base-content/50 hover:text-error transition-colors"
|
||||
<button
|
||||
class="text-xs text-base-content/50 hover:text-primary transition-colors"
|
||||
onclick={() => editarMensagem(mensagem)}
|
||||
title="Editar mensagem"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
class="text-xs text-base-content/50 hover:text-error transition-colors"
|
||||
onclick={() => deletarMensagem(mensagem._id, false)}
|
||||
title="Deletar mensagem"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
title="Deletar mensagem"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
{:else if isAdmin?.data}
|
||||
<!-- Ações para admin deletar mensagens de outros -->
|
||||
<button
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { authStore } from "$lib/stores/auth.svelte";
|
||||
import UserStatusBadge from "./UserStatusBadge.svelte";
|
||||
import UserAvatar from "./UserAvatar.svelte";
|
||||
import { MessageSquare, User, Users, Video, X, Search, ChevronRight, Plus, UserX } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
@@ -134,38 +135,21 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm" onclick={onClose}>
|
||||
<div
|
||||
class="bg-base-100 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[85vh] flex flex-col m-4 border border-base-300 overflow-hidden"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
style="box-shadow: 0 20px 60px -15px rgba(0, 0, 0, 0.3);"
|
||||
>
|
||||
<!-- Header com gradiente -->
|
||||
<div class="flex items-center justify-between px-6 py-5 border-b border-base-300 relative overflow-hidden"
|
||||
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);">
|
||||
<div class="absolute inset-0 opacity-20" style="background: radial-gradient(circle at 20% 50%, rgba(255,255,255,0.3) 0%, transparent 50%);"></div>
|
||||
<h2 class="text-2xl font-bold text-white relative z-10 flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z" />
|
||||
</svg>
|
||||
<dialog class="modal modal-open" onclick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div class="modal-box max-w-2xl max-h-[85vh] flex flex-col p-0" onclick={(e) => e.stopPropagation()}>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300">
|
||||
<h2 class="text-2xl font-bold flex items-center gap-2">
|
||||
<MessageSquare class="w-6 h-6 text-primary" />
|
||||
Nova Conversa
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle hover:bg-white/20 transition-all duration-200 relative z-10"
|
||||
class="btn btn-ghost btn-sm btn-circle"
|
||||
onclick={onClose}
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5 text-white"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -184,9 +168,7 @@
|
||||
searchQuery = "";
|
||||
}}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
<User class="w-4 h-4" />
|
||||
Individual
|
||||
</button>
|
||||
<button
|
||||
@@ -202,9 +184,7 @@
|
||||
searchQuery = "";
|
||||
}}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z" />
|
||||
</svg>
|
||||
<Users class="w-4 h-4" />
|
||||
Grupo
|
||||
</button>
|
||||
<button
|
||||
@@ -220,9 +200,7 @@
|
||||
searchQuery = "";
|
||||
}}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-4 h-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
|
||||
</svg>
|
||||
<Video class="w-4 h-4" />
|
||||
Sala de Reunião
|
||||
</button>
|
||||
</div>
|
||||
@@ -280,20 +258,11 @@
|
||||
<div class="mb-4 relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="🔍 Buscar usuários por nome, email ou matrícula..."
|
||||
placeholder="Buscar usuários por nome, email ou matrícula..."
|
||||
class="input input-bordered w-full pl-10 focus:input-primary transition-colors"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/40"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||
</svg>
|
||||
<Search class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/40" />
|
||||
</div>
|
||||
|
||||
<!-- Lista de usuários -->
|
||||
@@ -351,9 +320,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Ícone de seta para individual -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 text-base-content/40">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
<ChevronRight class="w-5 h-5 text-base-content/40" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
@@ -364,9 +331,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-16 h-16 text-base-content/30 mb-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||
</svg>
|
||||
<UserX class="w-16 h-16 text-base-content/30 mb-4" />
|
||||
<p class="text-base-content/70 font-medium">
|
||||
{searchQuery.trim() ? "Nenhum usuário encontrado" : "Nenhum usuário disponível"}
|
||||
</p>
|
||||
@@ -391,9 +356,7 @@
|
||||
<span class="loading loading-spinner"></span>
|
||||
Criando grupo...
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
<Plus class="w-5 h-5" />
|
||||
Criar Grupo
|
||||
{/if}
|
||||
</button>
|
||||
@@ -413,9 +376,7 @@
|
||||
<span class="loading loading-spinner"></span>
|
||||
Criando sala...
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
<Plus class="w-5 h-5" />
|
||||
Criar Sala de Reunião
|
||||
{/if}
|
||||
</button>
|
||||
@@ -425,5 +386,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" onclick={onClose}>fechar</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import UserAvatar from "./UserAvatar.svelte";
|
||||
import UserStatusBadge from "./UserStatusBadge.svelte";
|
||||
import { X, Users, UserPlus, ArrowUp, ArrowDown, Trash2, Search } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
conversaId: Id<"conversas">;
|
||||
@@ -151,19 +152,15 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50" onclick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}}>
|
||||
<div
|
||||
class="bg-base-100 rounded-xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col m-4"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<dialog class="modal modal-open" onclick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div class="modal-box max-w-2xl max-h-[80vh] flex flex-col p-0" onclick={(e) => e.stopPropagation()}>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Gerenciar Sala de Reunião</h2>
|
||||
<h2 class="text-xl font-semibold flex items-center gap-2">
|
||||
<Users class="w-5 h-5 text-primary" />
|
||||
Gerenciar Sala de Reunião
|
||||
</h2>
|
||||
<p class="text-sm text-base-content/60">{conversa()?.nome || "Sem nome"}</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -172,16 +169,7 @@
|
||||
onclick={onClose}
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -190,16 +178,18 @@
|
||||
<div class="tabs tabs-boxed p-4">
|
||||
<button
|
||||
type="button"
|
||||
class={`tab ${activeTab === "participantes" ? "tab-active" : ""}`}
|
||||
class={`tab flex items-center gap-2 ${activeTab === "participantes" ? "tab-active" : ""}`}
|
||||
onclick={() => (activeTab = "participantes")}
|
||||
>
|
||||
<Users class="w-4 h-4" />
|
||||
Participantes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`tab ${activeTab === "adicionar" ? "tab-active" : ""}`}
|
||||
class={`tab flex items-center gap-2 ${activeTab === "adicionar" ? "tab-active" : ""}`}
|
||||
onclick={() => (activeTab = "adicionar")}
|
||||
>
|
||||
<UserPlus class="w-4 h-4" />
|
||||
Adicionar Participante
|
||||
</button>
|
||||
</div>
|
||||
@@ -209,7 +199,9 @@
|
||||
{#if error}
|
||||
<div class="mx-6 mt-2 alert alert-error">
|
||||
<span>{error}</span>
|
||||
<button type="button" class="btn btn-sm btn-ghost" onclick={() => (error = null)}>✕</button>
|
||||
<button type="button" class="btn btn-sm btn-ghost" onclick={() => (error = null)}>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -269,7 +261,7 @@
|
||||
{#if isLoading && loading?.includes("rebaixar")}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
⬇️
|
||||
<ArrowDown class="w-4 h-4" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
@@ -283,7 +275,7 @@
|
||||
{#if isLoading && loading?.includes("promover")}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
⬆️
|
||||
<ArrowUp class="w-4 h-4" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
@@ -297,7 +289,7 @@
|
||||
{#if isLoading && loading?.includes("remover")}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
✕
|
||||
<Trash2 class="w-4 h-4" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
@@ -312,13 +304,14 @@
|
||||
</div>
|
||||
{:else if activeTab === "adicionar" && isAdmin}
|
||||
<!-- Adicionar Participante -->
|
||||
<div class="mb-4">
|
||||
<div class="mb-4 relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar usuários..."
|
||||
class="input input-bordered w-full"
|
||||
class="input input-bordered w-full pl-10"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
<Search class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/40" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
@@ -356,7 +349,7 @@
|
||||
{#if isLoading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<span class="text-primary">+</span>
|
||||
<UserPlus class="w-5 h-5 text-primary" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
@@ -376,5 +369,8 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" onclick={onClose}>fechar</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
|
||||
@@ -99,70 +99,21 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50"
|
||||
onclick={onClose}
|
||||
onkeydown={(e) => e.key === 'Escape' && onClose()}
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="bg-base-100 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col m-4"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<!-- Header ULTRA MODERNO -->
|
||||
<div class="flex items-center justify-between px-6 py-5 relative overflow-hidden" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);">
|
||||
<!-- Efeitos de fundo -->
|
||||
<div class="absolute inset-0 opacity-20" style="background: linear-gradient(45deg, transparent 30%, rgba(255,255,255,0.1) 50%, transparent 70%); animation: shimmer 3s infinite;"></div>
|
||||
|
||||
<h2 id="modal-title" class="text-xl font-bold flex items-center gap-3 text-white relative z-10">
|
||||
<!-- Ícone moderno de relógio -->
|
||||
<div class="relative flex items-center justify-center w-10 h-10 rounded-xl" style="background: rgba(255,255,255,0.2); backdrop-filter: blur(10px); box-shadow: 0 4px 12px rgba(0,0,0,0.1);">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span style="text-shadow: 0 2px 8px rgba(0,0,0,0.3);">Agendar Mensagem</span>
|
||||
<dialog class="modal modal-open" onclick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div class="modal-box max-w-2xl max-h-[90vh] flex flex-col p-0" onclick={(e) => e.stopPropagation()}>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300">
|
||||
<h2 id="modal-title" class="text-xl font-bold flex items-center gap-2">
|
||||
<Clock class="w-5 h-5 text-primary" />
|
||||
Agendar Mensagem
|
||||
</h2>
|
||||
|
||||
<!-- Botão fechar moderno -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center w-10 h-10 rounded-xl transition-all duration-300 group relative overflow-hidden z-10"
|
||||
style="background: rgba(255,255,255,0.15); backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.2);"
|
||||
class="btn btn-ghost btn-sm btn-circle"
|
||||
onclick={onClose}
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<div class="absolute inset-0 bg-red-500/0 group-hover:bg-red-500/30 transition-colors duration-300"></div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-5 h-5 text-white relative z-10 group-hover:scale-110 group-hover:rotate-90 transition-all duration-300"
|
||||
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -220,20 +171,7 @@
|
||||
|
||||
{#if getPreviewText()}
|
||||
<div class="alert alert-info">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-6 h-6"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<Clock class="w-6 h-6" />
|
||||
<span>{getPreviewText()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -255,19 +193,7 @@
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span>Agendando...</span>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-5 h-5 group-hover:scale-110 transition-transform"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
<Clock class="w-5 h-5 group-hover:scale-110 transition-transform" />
|
||||
<span class="group-hover:scale-105 transition-transform">Agendar</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -286,20 +212,7 @@
|
||||
{#each mensagensAgendadas.data as msg (msg._id)}
|
||||
<div class="flex items-start gap-3 p-3 bg-base-100 rounded-lg">
|
||||
<div class="flex-shrink-0 mt-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5 text-primary"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<Clock class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -320,21 +233,7 @@
|
||||
aria-label="Cancelar"
|
||||
>
|
||||
<div class="absolute inset-0 bg-error/0 group-hover:bg-error/20 transition-colors duration-300"></div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-5 h-5 text-error relative z-10 group-hover:scale-110 transition-transform"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6"/>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
<line x1="10" y1="11" x2="10" y2="17"/>
|
||||
<line x1="14" y1="11" x2="14" y2="17"/>
|
||||
</svg>
|
||||
<Trash2 class="w-5 h-5 text-error relative z-10 group-hover:scale-110 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -345,20 +244,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center py-8 text-base-content/50">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-12 h-12 mx-auto mb-2 opacity-50"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<Clock class="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p class="text-sm">Nenhuma mensagem agendada</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -366,16 +252,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Efeito shimmer para o header */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" onclick={onClose}>fechar</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
@@ -4,4 +4,7 @@ import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()],
|
||||
resolve: {
|
||||
dedupe: ["lucide-svelte"],
|
||||
},
|
||||
});
|
||||
|
||||
6
bun.lock
6
bun.lock
@@ -6,7 +6,7 @@
|
||||
"dependencies": {
|
||||
"@tanstack/svelte-form": "^1.23.8",
|
||||
"chart.js": "^4.5.1",
|
||||
"lucide-svelte": "^0.548.0",
|
||||
"lucide-svelte": "^0.552.0",
|
||||
"svelte-chartjs": "^3.1.5",
|
||||
"svelte-sonner": "^1.0.5",
|
||||
},
|
||||
@@ -61,7 +61,7 @@
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@dicebear/avataaars": "^9.2.4",
|
||||
"convex": "^1.17.4",
|
||||
"convex": "catalog:",
|
||||
"nodemailer": "^7.0.10",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -624,7 +624,7 @@
|
||||
|
||||
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
|
||||
|
||||
"lucide-svelte": ["lucide-svelte@0.548.0", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5.0.0-next.42" } }, "sha512-aW2BfHWBLWf/XPSKytTPV16AWfFeFIJeUyOg7eHY2rhzVQ0u0LIvoS4pm2oskr+OJVw+NsS8fPvlBVqPfUO1XQ=="],
|
||||
"lucide-svelte": ["lucide-svelte@0.552.0", "", { "peerDependencies": { "svelte": "^3 || ^4 || ^5.0.0-next.42" } }, "sha512-zynJ64KOsuQG3I4tSqfvvl7Kc9x4mWkppbxsuyrbegQwma9HFhBp4aE6HuQNF4c3pS0AHWHki5CAMs5m3QXA5w=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"dependencies": {
|
||||
"@tanstack/svelte-form": "^1.23.8",
|
||||
"chart.js": "^4.5.1",
|
||||
"lucide-svelte": "^0.548.0",
|
||||
"lucide-svelte": "^0.552.0",
|
||||
"svelte-chartjs": "^3.1.5",
|
||||
"svelte-sonner": "^1.0.5"
|
||||
},
|
||||
|
||||
@@ -369,6 +369,7 @@ export const enviarMensagem = mutation({
|
||||
await ctx.db.patch(args.conversaId, {
|
||||
ultimaMensagem: args.conteudo.substring(0, 100),
|
||||
ultimaMensagemTimestamp: Date.now(),
|
||||
ultimaMensagemRemetenteId: usuarioAtual._id, // Guardar ID do remetente da última mensagem
|
||||
});
|
||||
|
||||
// Criar notificações para participantes (com tratamento de erro)
|
||||
@@ -1815,10 +1816,10 @@ export const listarAgendamentosChat = query({
|
||||
return mensagensEnriquecidas
|
||||
.filter((m): m is NonNullable<typeof m> => m !== null)
|
||||
.sort((a, b) => {
|
||||
const dataA = a.agendadaPara ?? 0;
|
||||
const dataB = b.agendadaPara ?? 0;
|
||||
return dataA - dataB;
|
||||
});
|
||||
const dataA = a.agendadaPara ?? 0;
|
||||
const dataB = b.agendadaPara ?? 0;
|
||||
return dataA - dataB;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2071,7 +2072,7 @@ export const buscarMensagens = query({
|
||||
// Filtrar por remetente (já verificado acima, mas garantir novamente)
|
||||
if (args.remetenteId) {
|
||||
if (m.remetenteId !== args.remetenteId) {
|
||||
return false;
|
||||
return false;
|
||||
}
|
||||
// Verificar novamente se o remetente é participante da conversa específica desta mensagem
|
||||
if (!conversaDaMensagem.participantes.includes(args.remetenteId)) {
|
||||
@@ -2269,6 +2270,7 @@ export const enviarMensagensAgendadas = internalMutation({
|
||||
await ctx.db.patch(mensagem.conversaId, {
|
||||
ultimaMensagem: mensagem.conteudo.substring(0, 100),
|
||||
ultimaMensagemTimestamp: agora,
|
||||
ultimaMensagemRemetenteId: mensagem.remetenteId, // Guardar ID do remetente
|
||||
});
|
||||
|
||||
// Criar notificações para outros participantes
|
||||
|
||||
@@ -626,6 +626,7 @@ export default defineSchema({
|
||||
administradores: v.optional(v.array(v.id("usuarios"))), // IDs dos administradores (apenas para sala_reuniao)
|
||||
ultimaMensagem: v.optional(v.string()),
|
||||
ultimaMensagemTimestamp: v.optional(v.number()),
|
||||
ultimaMensagemRemetenteId: v.optional(v.id("usuarios")), // ID do remetente da última mensagem
|
||||
criadoPor: v.id("usuarios"),
|
||||
criadoEm: v.number(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user