mirror of
https://git.v0id.ovh/n3wt-innov/n3wt-school.git
synced 2026-01-28 23:43:22 +00:00
feat: mise en place de la messagerie [#17]
This commit is contained in:
@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { sendMessage, searchRecipients } from '@/app/actions/messagerieAction';
|
||||
import { sendEmail, searchRecipients } from '@/app/actions/emailAction';
|
||||
import { fetchSmtpSettings } from '@/app/actions/settingsAction';
|
||||
import { useNotification } from '@/context/NotificationContext';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
@ -56,7 +56,7 @@ export default function EmailSender({ csrfToken }) {
|
||||
};
|
||||
|
||||
try {
|
||||
await sendMessage(data);
|
||||
await sendEmail(data);
|
||||
showNotification('Email envoyé avec succès.', 'success', 'Succès');
|
||||
// Réinitialiser les champs après succès
|
||||
setRecipients([]);
|
||||
|
||||
@ -1,88 +1,17 @@
|
||||
// filepath: d:\Dev\n3wt-innov\n3wt-school\Front-End\src\components\Admin\InstantMessaging.js
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import Chat from '@/components/Chat';
|
||||
import InstantChat from '@/components/Chat/InstantChat';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import {
|
||||
fetchConversations,
|
||||
sendMessage,
|
||||
searchRecipients,
|
||||
} from '@/app/actions/messagerieAction';
|
||||
import RecipientInput from '@/components/RecipientInput';
|
||||
import Modal from '@/components/Modal';
|
||||
import logger from '@/utils/logger';
|
||||
|
||||
export default function InstantMessaging({ csrfToken }) {
|
||||
const { user, selectedEstablishmentId } = useEstablishment();
|
||||
const [discussions, setDiscussions] = useState([]);
|
||||
const [recipients, setRecipients] = useState([]); // Liste des correspondants sélectionnés
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [firstMessage, setFirstMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchConversations(user.id).then(setDiscussions);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// Fonction pour ajouter une nouvelle discussion avec plusieurs correspondants
|
||||
const handleCreateDiscussion = async () => {
|
||||
if (!user || recipients.length === 0 || !firstMessage.trim()) return;
|
||||
for (const recipient of recipients) {
|
||||
await sendMessage({
|
||||
emetteur: user.id,
|
||||
destinataire: recipient.id,
|
||||
objet: '',
|
||||
corpus: firstMessage,
|
||||
conversation_id: undefined, // L'API générera un nouvel ID
|
||||
});
|
||||
}
|
||||
setRecipients([]);
|
||||
setFirstMessage('');
|
||||
setShowModal(false);
|
||||
fetchConversations(user.id).then(setDiscussions);
|
||||
};
|
||||
|
||||
if (!user) return <div>Chargement...</div>;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<Chat
|
||||
userProfileId={user.id}
|
||||
<div className="h-full flex flex-col">
|
||||
<InstantChat
|
||||
userProfileId={user.user_id}
|
||||
establishmentId={selectedEstablishmentId}
|
||||
discussions={discussions}
|
||||
setDiscussions={setDiscussions}
|
||||
onShowCreateDiscussion={() => setShowModal(true)}
|
||||
/>
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
setIsOpen={setShowModal}
|
||||
title="Nouvelle discussion"
|
||||
modalClassName="w-full max-w-xs sm:max-w-md"
|
||||
>
|
||||
<div className="p-2 sm:p-4">
|
||||
<h3 className="text-lg font-bold mb-2">Nouvelle discussion</h3>
|
||||
<RecipientInput
|
||||
label="Rechercher un correspondant"
|
||||
recipients={recipients}
|
||||
setRecipients={setRecipients}
|
||||
searchRecipients={searchRecipients}
|
||||
establishmentId={selectedEstablishmentId}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={firstMessage}
|
||||
onChange={(e) => setFirstMessage(e.target.value)}
|
||||
placeholder="Premier message"
|
||||
className="w-full p-2 mb-2 border rounded"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreateDiscussion}
|
||||
className="w-full p-2 bg-green-500 text-white rounded-lg"
|
||||
>
|
||||
Démarrer la discussion
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,175 +0,0 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { SendHorizontal, Plus } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import {
|
||||
fetchConversations,
|
||||
fetchMessages,
|
||||
sendMessage,
|
||||
markAsRead,
|
||||
} from '@/app/actions/messagerieAction';
|
||||
|
||||
export default function Chat({
|
||||
userProfileId,
|
||||
establishmentId,
|
||||
discussions: discussionsProp,
|
||||
setDiscussions: setDiscussionsProp,
|
||||
onCreateDiscussion,
|
||||
onShowCreateDiscussion,
|
||||
}) {
|
||||
const [discussions, setDiscussions] = useState(discussionsProp || []);
|
||||
const [selectedDiscussion, setSelectedDiscussion] = useState(null);
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [newMessage, setNewMessage] = useState('');
|
||||
const messagesEndRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (userProfileId) {
|
||||
fetchConversations(userProfileId).then(setDiscussions);
|
||||
}
|
||||
}, [userProfileId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDiscussion) {
|
||||
fetchMessages(selectedDiscussion.conversation_id).then(setMessages);
|
||||
// Marquer comme lu
|
||||
markAsRead(selectedDiscussion.conversation_id, userProfileId);
|
||||
}
|
||||
}, [selectedDiscussion, userProfileId]);
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (newMessage.trim() && selectedDiscussion) {
|
||||
await sendMessage({
|
||||
conversation_id: selectedDiscussion.conversation_id,
|
||||
emetteur: userProfileId,
|
||||
destinataire: selectedDiscussion.interlocuteur.id,
|
||||
corpus: newMessage,
|
||||
objet: '',
|
||||
});
|
||||
setNewMessage('');
|
||||
fetchMessages(selectedDiscussion.conversation_id).then(setMessages);
|
||||
fetchConversations(userProfileId).then(setDiscussions);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleSendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
{/* Bandeau droit : Liste des discussions */}
|
||||
<div className="w-1/4 min-w-[280px] bg-gray-100 border-r border-gray-300 p-4 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold">Discussions</h2>
|
||||
<button
|
||||
className="p-2 rounded-full bg-blue-500 hover:bg-blue-600 text-white shadow"
|
||||
title="Nouvelle discussion"
|
||||
onClick={onShowCreateDiscussion}
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{discussions && discussions.length > 0 ? (
|
||||
discussions.map((discussion) => (
|
||||
<div
|
||||
key={discussion.id}
|
||||
className={`flex items-center p-2 mb-2 cursor-pointer rounded transition-colors ${
|
||||
selectedDiscussion?.id === discussion.id
|
||||
? 'bg-blue-100'
|
||||
: 'hover:bg-gray-200'
|
||||
}`}
|
||||
onClick={() => setSelectedDiscussion(discussion)}
|
||||
>
|
||||
<Image
|
||||
src={discussion.profilePic}
|
||||
alt={`${discussion.name}'s profile`}
|
||||
className="w-10 h-10 rounded-full mr-3"
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{discussion.name}</p>
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
{discussion.lastMessage}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">
|
||||
{discussion.lastMessageDate &&
|
||||
new Date(discussion.lastMessageDate).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500">Aucune discussion disponible.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zone de chat */}
|
||||
<div className="flex-1 flex flex-col bg-white">
|
||||
{/* En-tête du chat */}
|
||||
{selectedDiscussion && (
|
||||
<div className="flex items-center p-4 border-b border-gray-300">
|
||||
<Image
|
||||
src={selectedDiscussion.profilePic}
|
||||
alt={`${selectedDiscussion.name}'s profile`}
|
||||
className="w-10 h-10 rounded-full mr-3"
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
<h2 className="text-lg font-bold">{selectedDiscussion.name}</h2>
|
||||
</div>
|
||||
)}
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{selectedDiscussion &&
|
||||
messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex mb-4 ${
|
||||
message.isResponse ? 'justify-start' : 'justify-end'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`p-3 rounded-lg max-w-xs ${
|
||||
message.isResponse
|
||||
? 'bg-gray-200 text-gray-800'
|
||||
: 'bg-blue-500 text-white'
|
||||
}`}
|
||||
>
|
||||
<p>{message.corpus}</p>
|
||||
<span className="text-xs text-gray-500 block mt-1">
|
||||
{message.date &&
|
||||
new Date(message.date).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
{/* Champ de saisie */}
|
||||
{selectedDiscussion && (
|
||||
<div className="p-4 border-t border-gray-300 flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={newMessage}
|
||||
onChange={(e) => setNewMessage(e.target.value)}
|
||||
className="flex-1 p-2 border border-gray-300 rounded-lg mr-2"
|
||||
placeholder="Écrire un message..."
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSendMessage}
|
||||
className="p-2 bg-blue-500 text-white rounded-lg"
|
||||
>
|
||||
<SendHorizontal />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
Front-End/src/components/Chat/ConnectionStatus.js
Normal file
79
Front-End/src/components/Chat/ConnectionStatus.js
Normal file
@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import { Wifi, WifiOff, RotateCcw } from 'lucide-react';
|
||||
|
||||
const ConnectionStatus = ({ status, onReconnect }) => {
|
||||
const getStatusInfo = () => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return {
|
||||
icon: <Wifi className="w-4 h-4" />,
|
||||
text: 'Connecté',
|
||||
className: 'text-green-600 bg-green-50 border-green-200',
|
||||
};
|
||||
case 'disconnected':
|
||||
return {
|
||||
icon: <WifiOff className="w-4 h-4" />,
|
||||
text: 'Déconnecté',
|
||||
className: 'text-red-600 bg-red-50 border-red-200',
|
||||
};
|
||||
case 'reconnecting':
|
||||
return {
|
||||
icon: <RotateCcw className="w-4 h-4 animate-spin" />,
|
||||
text: 'Reconnexion...',
|
||||
className: 'text-yellow-600 bg-yellow-50 border-yellow-200',
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
icon: <WifiOff className="w-4 h-4" />,
|
||||
text: 'Erreur de connexion',
|
||||
className: 'text-red-600 bg-red-50 border-red-200',
|
||||
};
|
||||
case 'failed':
|
||||
return {
|
||||
icon: <WifiOff className="w-4 h-4" />,
|
||||
text: 'Connexion échouée',
|
||||
className: 'text-red-600 bg-red-50 border-red-200',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <WifiOff className="w-4 h-4" />,
|
||||
text: 'Inconnu',
|
||||
className: 'text-gray-600 bg-gray-50 border-gray-200',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (status === 'connected') {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-2 border rounded-lg text-green-600 bg-green-50 border-green-200">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Wifi className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">Connecté</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { icon, text, className } = getStatusInfo();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between px-3 py-2 border rounded-lg ${className}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
{icon}
|
||||
<span className="text-sm font-medium">{text}</span>
|
||||
</div>
|
||||
{(status === 'failed' || status === 'error') && onReconnect && (
|
||||
<button
|
||||
onClick={onReconnect}
|
||||
className="text-sm underline hover:no-underline"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionStatus;
|
||||
196
Front-End/src/components/Chat/ConversationItem.js
Normal file
196
Front-End/src/components/Chat/ConversationItem.js
Normal file
@ -0,0 +1,196 @@
|
||||
import React from 'react';
|
||||
import { User, Trash2 } from 'lucide-react';
|
||||
import { getGravatarUrl } from '@/utils/gravatar';
|
||||
|
||||
const ConversationItem = ({
|
||||
conversation,
|
||||
isSelected,
|
||||
onClick,
|
||||
onDelete, // Nouvelle prop pour la suppression
|
||||
unreadCount = 0,
|
||||
lastMessage,
|
||||
isTyping = false,
|
||||
userPresences = {}, // Nouveau prop pour les statuts de présence
|
||||
}) => {
|
||||
const formatTime = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInHours = (now - date) / (1000 * 60 * 60);
|
||||
|
||||
if (diffInHours < 24) {
|
||||
return date.toLocaleTimeString('fr-FR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
} else {
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getInterlocutorName = () => {
|
||||
if (conversation.interlocuteur) {
|
||||
// Si nous avons le nom et prénom, les utiliser
|
||||
if (
|
||||
conversation.interlocuteur.first_name &&
|
||||
conversation.interlocuteur.last_name
|
||||
) {
|
||||
return `${conversation.interlocuteur.first_name} ${conversation.interlocuteur.last_name}`;
|
||||
}
|
||||
// Sinon, utiliser l'email comme fallback
|
||||
if (conversation.interlocuteur.email) {
|
||||
return conversation.interlocuteur.email;
|
||||
}
|
||||
}
|
||||
return conversation.name || 'Utilisateur inconnu';
|
||||
};
|
||||
|
||||
const getLastMessageText = () => {
|
||||
if (isTyping) {
|
||||
return (
|
||||
<span className="text-emerald-500 italic">Tape un message...</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (lastMessage) {
|
||||
return lastMessage.content || lastMessage.corpus || 'Message...';
|
||||
}
|
||||
|
||||
if (conversation.last_message) {
|
||||
return (
|
||||
conversation.last_message.content ||
|
||||
conversation.last_message.corpus ||
|
||||
'Message...'
|
||||
);
|
||||
}
|
||||
|
||||
return 'Aucun message';
|
||||
};
|
||||
|
||||
const getLastMessageTime = () => {
|
||||
if (lastMessage) {
|
||||
return formatTime(lastMessage.created_at || lastMessage.date_envoi);
|
||||
}
|
||||
|
||||
if (conversation.last_message) {
|
||||
return formatTime(
|
||||
conversation.last_message.created_at ||
|
||||
conversation.last_message.date_envoi
|
||||
);
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
const getUserPresenceStatus = () => {
|
||||
if (conversation.interlocuteur?.id) {
|
||||
const presence = userPresences[conversation.interlocuteur.id];
|
||||
return presence?.status || 'offline';
|
||||
}
|
||||
return 'offline';
|
||||
};
|
||||
|
||||
const getPresenceColor = (status) => {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return 'bg-emerald-400';
|
||||
case 'away':
|
||||
return 'bg-yellow-400';
|
||||
case 'busy':
|
||||
return 'bg-red-400';
|
||||
case 'offline':
|
||||
default:
|
||||
return 'bg-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
const getPresenceLabel = (status) => {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return 'En ligne';
|
||||
case 'away':
|
||||
return 'Absent';
|
||||
case 'busy':
|
||||
return 'Occupé';
|
||||
case 'offline':
|
||||
default:
|
||||
return 'Hors ligne';
|
||||
}
|
||||
};
|
||||
|
||||
const presenceStatus = getUserPresenceStatus();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group flex items-center p-3 cursor-pointer rounded-lg transition-all duration-200 hover:bg-gray-50 ${
|
||||
isSelected
|
||||
? 'bg-emerald-50 border-l-4 border-emerald-500'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className="relative">
|
||||
<img
|
||||
src={getGravatarUrl(
|
||||
conversation.interlocuteur?.email || 'default',
|
||||
48
|
||||
)}
|
||||
alt={`Avatar de ${getInterlocutorName()}`}
|
||||
className="w-12 h-12 rounded-full object-cover shadow-md"
|
||||
/>
|
||||
|
||||
{/* Indicateur de statut en ligne */}
|
||||
<div
|
||||
className={`absolute -bottom-0.5 -right-0.5 w-4 h-4 ${getPresenceColor(presenceStatus)} border-2 border-white rounded-full`}
|
||||
title={getPresenceLabel(presenceStatus)}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{/* Contenu de la conversation */}
|
||||
<div className="flex-1 ml-3 overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3
|
||||
className={`font-semibold truncate ${
|
||||
isSelected ? 'text-emerald-700' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{getInterlocutorName()}
|
||||
</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
{unreadCount > 0 && (
|
||||
<span className="bg-red-500 text-white text-xs rounded-full w-4 h-4 text-center"></span>
|
||||
)}
|
||||
<span className="text-xs text-gray-500">
|
||||
{getLastMessageTime()}
|
||||
</span>
|
||||
{/* Bouton de suppression */}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Empêcher la sélection de la conversation
|
||||
onDelete();
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 hover:bg-red-100 p-1 rounded transition-all duration-200"
|
||||
title="Supprimer la conversation"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500 hover:text-red-700" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className={`text-sm truncate mt-1 ${isTyping ? '' : 'text-gray-600'}`}
|
||||
>
|
||||
{getLastMessageText()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConversationItem;
|
||||
115
Front-End/src/components/Chat/FileAttachment.js
Normal file
115
Front-End/src/components/Chat/FileAttachment.js
Normal file
@ -0,0 +1,115 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Download,
|
||||
FileText,
|
||||
Image,
|
||||
Film,
|
||||
Music,
|
||||
Archive,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
const FileAttachment = ({
|
||||
fileName,
|
||||
fileSize,
|
||||
fileType,
|
||||
fileUrl,
|
||||
onDownload = null,
|
||||
}) => {
|
||||
// Obtenir l'icône en fonction du type de fichier
|
||||
const getFileIcon = (type) => {
|
||||
if (type.startsWith('image/')) {
|
||||
return <Image className="w-6 h-6 text-blue-500" />;
|
||||
}
|
||||
if (type.startsWith('video/')) {
|
||||
return <Film className="w-6 h-6 text-purple-500" />;
|
||||
}
|
||||
if (type.startsWith('audio/')) {
|
||||
return <Music className="w-6 h-6 text-green-500" />;
|
||||
}
|
||||
if (type.includes('pdf')) {
|
||||
return <FileText className="w-6 h-6 text-red-500" />;
|
||||
}
|
||||
if (type.includes('zip') || type.includes('rar')) {
|
||||
return <Archive className="w-6 h-6 text-yellow-500" />;
|
||||
}
|
||||
return <FileText className="w-6 h-6 text-gray-500" />;
|
||||
};
|
||||
|
||||
// Formater la taille du fichier
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes) return '';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// Gérer le téléchargement
|
||||
const handleDownload = () => {
|
||||
if (onDownload) {
|
||||
onDownload();
|
||||
} else if (fileUrl) {
|
||||
const link = document.createElement('a');
|
||||
link.href = fileUrl;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
};
|
||||
|
||||
// Vérifier si c'est une image pour afficher un aperçu
|
||||
const isImage = fileType && fileType.startsWith('image/');
|
||||
|
||||
return (
|
||||
<div className="max-w-sm">
|
||||
{isImage && fileUrl ? (
|
||||
// Affichage pour les images
|
||||
<div className="relative group">
|
||||
<img
|
||||
src={fileUrl}
|
||||
alt={fileName}
|
||||
className="max-w-full h-auto rounded-lg cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => window.open(fileUrl, '_blank')}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all rounded-lg flex items-center justify-center">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="opacity-0 group-hover:opacity-100 bg-white bg-opacity-90 hover:bg-opacity-100 rounded-full p-2 transition-all"
|
||||
>
|
||||
<Download className="w-4 h-4 text-gray-700" />
|
||||
</button>
|
||||
</div>
|
||||
{fileName && (
|
||||
<p className="mt-1 text-xs text-gray-500 truncate">{fileName}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Affichage pour les autres fichiers
|
||||
<div className="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg border hover:bg-gray-100 transition-colors">
|
||||
<div className="flex-shrink-0">{getFileIcon(fileType)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{fileName || 'Fichier sans nom'}
|
||||
</p>
|
||||
{fileSize && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatFileSize(fileSize)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="flex-shrink-0 p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
title="Télécharger"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileAttachment;
|
||||
179
Front-End/src/components/Chat/FileUpload.js
Normal file
179
Front-End/src/components/Chat/FileUpload.js
Normal file
@ -0,0 +1,179 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Upload, FileText, Image, AlertCircle } from 'lucide-react';
|
||||
|
||||
const FileUpload = ({
|
||||
file,
|
||||
onUpload,
|
||||
onCancel,
|
||||
conversationId,
|
||||
senderId,
|
||||
maxSize = 10 * 1024 * 1024, // 10MB par défaut
|
||||
}) => {
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Vérifier le type de fichier et obtenir l'icône appropriée
|
||||
const getFileIcon = (fileType) => {
|
||||
if (fileType.startsWith('image/')) {
|
||||
return <Image className="w-8 h-8 text-blue-500" alt="Icône image" />;
|
||||
}
|
||||
return <FileText className="w-8 h-8 text-gray-500" alt="Icône fichier" />;
|
||||
};
|
||||
|
||||
// Formater la taille du fichier
|
||||
const formatFileSize = (bytes) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// Vérifier si le fichier est valide
|
||||
const isValidFile = () => {
|
||||
const allowedTypes = [
|
||||
'image/jpeg',
|
||||
'image/jpg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/plain',
|
||||
];
|
||||
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.size > maxSize) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Gérer l'upload
|
||||
const handleUpload = async () => {
|
||||
if (!isValidFile()) {
|
||||
setError('Type de fichier non autorisé ou fichier trop volumineux');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await onUpload(
|
||||
file,
|
||||
conversationId,
|
||||
senderId,
|
||||
setUploadProgress
|
||||
);
|
||||
// L'upload s'est bien passé, le parent gère la suite
|
||||
} catch (error) {
|
||||
setError(error.message || "Erreur lors de l'upload");
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!file) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4 shadow-lg">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
{getFileIcon(file.type)}
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 truncate max-w-xs">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">{formatFileSize(file.size)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={isUploading}
|
||||
className="text-gray-400 hover:text-gray-600 disabled:opacity-50"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Prévisualisation pour les images */}
|
||||
{file.type.startsWith('image/') && (
|
||||
<div className="mb-3">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={URL.createObjectURL(file)}
|
||||
alt="Aperçu du fichier sélectionné"
|
||||
className="max-w-full h-32 object-cover rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation du fichier */}
|
||||
{!isValidFile() && (
|
||||
<div className="mb-3 p-2 bg-red-50 border border-red-200 rounded-lg flex items-center space-x-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-500" />
|
||||
<span className="text-sm text-red-700">
|
||||
{file.size > maxSize
|
||||
? `Fichier trop volumineux (max ${formatFileSize(maxSize)})`
|
||||
: 'Type de fichier non autorisé'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Erreur d'upload */}
|
||||
{error && (
|
||||
<div className="mb-3 p-2 bg-red-50 border border-red-200 rounded-lg flex items-center space-x-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-500" />
|
||||
<span className="text-sm text-red-700">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Barre de progression */}
|
||||
{isUploading && (
|
||||
<div className="mb-3">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-sm text-gray-600">Upload en cours...</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
{Math.round(uploadProgress)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Boutons d'action */}
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
disabled={isUploading}
|
||||
className="px-3 py-2 text-sm text-gray-600 hover:text-gray-800 disabled:opacity-50"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
disabled={isUploading || !isValidFile()}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
<span>{isUploading ? 'Upload...' : 'Envoyer'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileUpload;
|
||||
1162
Front-End/src/components/Chat/InstantChat.js
Normal file
1162
Front-End/src/components/Chat/InstantChat.js
Normal file
File diff suppressed because it is too large
Load Diff
133
Front-End/src/components/Chat/MessageBubble.js
Normal file
133
Front-End/src/components/Chat/MessageBubble.js
Normal file
@ -0,0 +1,133 @@
|
||||
import React from 'react';
|
||||
import { format, isToday, isYesterday } from 'date-fns';
|
||||
import { fr } from 'date-fns/locale';
|
||||
import { Check, CheckCheck } from 'lucide-react';
|
||||
import FileAttachment from './FileAttachment';
|
||||
import { getGravatarUrl } from '@/utils/gravatar';
|
||||
|
||||
const MessageBubble = ({
|
||||
message,
|
||||
isOwnMessage,
|
||||
showAvatar = true,
|
||||
isRead = false,
|
||||
senderName = '',
|
||||
senderEmail = '', // Nouveau prop pour l'email du sender
|
||||
isFirstInGroup = true,
|
||||
isLastInGroup = true,
|
||||
showTime = true,
|
||||
}) => {
|
||||
const formatMessageTime = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
|
||||
const date = new Date(dateString);
|
||||
|
||||
if (isToday(date)) {
|
||||
return format(date, 'HH:mm', { locale: fr });
|
||||
} else if (isYesterday(date)) {
|
||||
return `Hier ${format(date, 'HH:mm', { locale: fr })}`;
|
||||
} else {
|
||||
return format(date, 'dd/MM HH:mm', { locale: fr });
|
||||
}
|
||||
};
|
||||
|
||||
const getMessageContent = () => {
|
||||
return message.content || message.corpus || '';
|
||||
};
|
||||
|
||||
const getMessageTime = () => {
|
||||
return message.created_at || message.date_envoi;
|
||||
};
|
||||
|
||||
const hasAttachment = () => {
|
||||
return (
|
||||
message.attachment &&
|
||||
(message.attachment.fileName || message.attachment.fileUrl)
|
||||
);
|
||||
};
|
||||
|
||||
const isFileOnlyMessage = () => {
|
||||
return hasAttachment() && !getMessageContent().trim();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group hover:bg-gray-50 px-4 py-1 ${isFirstInGroup ? 'mt-4' : 'mt-0.5'} message-appear`}
|
||||
onMouseEnter={() => {
|
||||
/* Peut ajouter des actions au hover */
|
||||
}}
|
||||
>
|
||||
<div className="flex">
|
||||
{/* Avatar - affiché seulement pour le premier message du groupe */}
|
||||
{showAvatar && isFirstInGroup && (
|
||||
<img
|
||||
src={getGravatarUrl(senderEmail || senderName, 40)}
|
||||
alt={`Avatar de ${senderName || 'Utilisateur'}`}
|
||||
className="w-10 h-10 rounded-full object-cover shadow-sm mr-3 flex-shrink-0 mt-0.5"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Espace pour aligner avec l'avatar quand il n'est pas affiché */}
|
||||
{(!showAvatar || !isFirstInGroup) && (
|
||||
<div className="w-10 mr-3 flex-shrink-0"></div>
|
||||
)}
|
||||
|
||||
{/* Contenu du message */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* En-tête du message (nom + heure) - seulement pour le premier message du groupe */}
|
||||
{isFirstInGroup && (
|
||||
<div className="flex items-baseline space-x-2 mb-1">
|
||||
<span className="font-semibold text-gray-900 text-sm">
|
||||
{senderName || (isOwnMessage ? 'Moi' : 'Utilisateur')}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatMessageTime(getMessageTime())}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fichier attaché */}
|
||||
{hasAttachment() && (
|
||||
<div className={`${getMessageContent().trim() ? 'mb-2' : ''}`}>
|
||||
<FileAttachment
|
||||
fileName={message.attachment.fileName}
|
||||
fileSize={message.attachment.fileSize}
|
||||
fileType={message.attachment.fileType}
|
||||
fileUrl={message.attachment.fileUrl}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contenu du message */}
|
||||
{getMessageContent().trim() && (
|
||||
<div className="text-sm leading-relaxed whitespace-pre-wrap break-words text-gray-800">
|
||||
{getMessageContent()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Indicateurs de lecture et heure pour les messages non-groupés */}
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
{/* Heure pour les messages qui ne sont pas le premier du groupe */}
|
||||
{!isFirstInGroup && (
|
||||
<span className="text-xs text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{formatMessageTime(getMessageTime())}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Indicateurs de lecture (uniquement pour nos messages) */}
|
||||
{isOwnMessage && (
|
||||
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{isRead ? (
|
||||
<CheckCheck className="w-3 h-3 text-green-500" title="Lu" />
|
||||
) : (
|
||||
<Check className="w-3 h-3 text-gray-400" title="Envoyé" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageBubble;
|
||||
233
Front-End/src/components/Chat/MessageInput.js
Normal file
233
Front-End/src/components/Chat/MessageInput.js
Normal file
@ -0,0 +1,233 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Send, Paperclip } from 'lucide-react';
|
||||
import FileUpload from './FileUpload';
|
||||
import { uploadFile } from '@/app/actions/messagerieAction';
|
||||
import logger from '@/utils/logger';
|
||||
|
||||
const MessageInput = ({
|
||||
onSendMessage,
|
||||
onTypingStart,
|
||||
onTypingStop,
|
||||
disabled = false,
|
||||
placeholder = 'Tapez votre message...',
|
||||
conversationId = null,
|
||||
senderId = null,
|
||||
}) => {
|
||||
const [message, setMessage] = useState('');
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const [selectedFile, setSelectedFile] = useState(null);
|
||||
const [showFileUpload, setShowFileUpload] = useState(false);
|
||||
const textareaRef = useRef(null);
|
||||
const typingTimeoutRef = useRef(null);
|
||||
|
||||
// Ajuster la hauteur du textarea automatiquement
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`;
|
||||
}
|
||||
}, [message]);
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const value = e.target.value;
|
||||
setMessage(value);
|
||||
|
||||
// Gestion du statut de frappe
|
||||
if (value.trim() && !isTyping) {
|
||||
setIsTyping(true);
|
||||
onTypingStart?.();
|
||||
}
|
||||
|
||||
// Réinitialiser le timeout de frappe
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
|
||||
typingTimeoutRef.current = setTimeout(() => {
|
||||
if (isTyping) {
|
||||
setIsTyping(false);
|
||||
onTypingStop?.();
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = () => {
|
||||
const trimmedMessage = message.trim();
|
||||
logger.debug('📝 MessageInput: handleSend appelé:', {
|
||||
message,
|
||||
trimmedMessage,
|
||||
disabled,
|
||||
});
|
||||
|
||||
if (!trimmedMessage || disabled) {
|
||||
logger.debug('❌ MessageInput: Message vide ou désactivé');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
'📤 MessageInput: Appel de onSendMessage avec:',
|
||||
trimmedMessage
|
||||
);
|
||||
onSendMessage(trimmedMessage);
|
||||
setMessage('');
|
||||
|
||||
// Arrêter le statut de frappe
|
||||
if (isTyping) {
|
||||
setIsTyping(false);
|
||||
onTypingStop?.();
|
||||
}
|
||||
|
||||
// Effacer le timeout
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
setShowFileUpload(true);
|
||||
}
|
||||
// Réinitialiser l'input file
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleFileUpload = async (
|
||||
file,
|
||||
conversationId,
|
||||
senderId,
|
||||
onProgress
|
||||
) => {
|
||||
try {
|
||||
const result = await uploadFile(
|
||||
file,
|
||||
conversationId,
|
||||
senderId,
|
||||
onProgress
|
||||
);
|
||||
|
||||
// Envoyer un message avec le fichier
|
||||
onSendMessage('', {
|
||||
type: 'file',
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
fileType: file.type,
|
||||
fileUrl: result.fileUrl,
|
||||
});
|
||||
|
||||
// Réinitialiser l'état
|
||||
setSelectedFile(null);
|
||||
setShowFileUpload(false);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelFileUpload = () => {
|
||||
setSelectedFile(null);
|
||||
setShowFileUpload(false);
|
||||
};
|
||||
|
||||
// Nettoyage du timeout lors du démontage du composant
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (typingTimeoutRef.current) {
|
||||
clearTimeout(typingTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="border-t border-gray-200 bg-white">
|
||||
{/* Aperçu d'upload de fichier */}
|
||||
{showFileUpload && selectedFile && (
|
||||
<div className="p-4 border-b border-gray-200">
|
||||
<FileUpload
|
||||
file={selectedFile}
|
||||
onUpload={handleFileUpload}
|
||||
onCancel={handleCancelFileUpload}
|
||||
conversationId={conversationId}
|
||||
senderId={senderId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zone de saisie */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-end space-x-3">
|
||||
{/* Zone de saisie */}
|
||||
<div className="flex-1 relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={message}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||
style={{ minHeight: '44px', maxHeight: '120px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Boutons à droite (trombone au-dessus, envoi en dessous) */}
|
||||
<div className="flex flex-col space-y-2 flex-shrink-0">
|
||||
{/* Bouton d'ajout de fichier */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="file"
|
||||
id="file-upload"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
accept="image/*,application/pdf,.doc,.docx,.xls,.xlsx,.txt"
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="flex items-center justify-center w-10 h-10 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-full cursor-pointer transition-colors"
|
||||
>
|
||||
<Paperclip className="w-5 h-5" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Bouton d'envoi */}
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!message.trim() || disabled}
|
||||
className={`flex items-center justify-center w-10 h-10 rounded-full transition-all ${
|
||||
message.trim() && !disabled
|
||||
? 'bg-blue-500 hover:bg-blue-600 text-white shadow-lg hover:shadow-xl'
|
||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<Send className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indicateur de limite de caractères (optionnel) */}
|
||||
{message.length > 900 && (
|
||||
<div className="mt-2 text-right">
|
||||
<span
|
||||
className={`text-xs ${
|
||||
message.length > 1000 ? 'text-red-500' : 'text-yellow-500'
|
||||
}`}
|
||||
>
|
||||
{message.length}/1000
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageInput;
|
||||
30
Front-End/src/components/Chat/TypingIndicator.js
Normal file
30
Front-End/src/components/Chat/TypingIndicator.js
Normal file
@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
|
||||
const TypingIndicator = ({ typingUsers = [] }) => {
|
||||
if (typingUsers.length === 0) return null;
|
||||
|
||||
const getTypingText = () => {
|
||||
if (typingUsers.length === 1) {
|
||||
return `${typingUsers[0]} tape un message...`;
|
||||
} else if (typingUsers.length === 2) {
|
||||
return `${typingUsers[0]} et ${typingUsers[1]} tapent un message...`;
|
||||
} else {
|
||||
return `${typingUsers[0]} et ${typingUsers.length - 1} autres tapent un message...`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center px-4 py-3 bg-gray-50 border-t border-gray-100 typing-indicator-enter">
|
||||
<div className="flex space-x-1 mr-3">
|
||||
<div className="w-2 h-2 bg-blue-400 rounded-full typing-dot"></div>
|
||||
<div className="w-2 h-2 bg-blue-400 rounded-full typing-dot"></div>
|
||||
<div className="w-2 h-2 bg-blue-400 rounded-full typing-dot"></div>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600 italic animate-pulse">
|
||||
{getTypingText()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TypingIndicator;
|
||||
@ -3,6 +3,7 @@ import { LogOut } from 'lucide-react';
|
||||
import { disconnect } from '@/app/actions/authAction';
|
||||
import { getGravatarUrl } from '@/utils/gravatar';
|
||||
import { useEstablishment } from '@/context/EstablishmentContext';
|
||||
import { useChatConnection } from '@/context/ChatConnectionContext';
|
||||
import DropdownMenu from '@/components/DropdownMenu';
|
||||
import { usePopup } from '@/context/PopupContext';
|
||||
import { getRightStr } from '@/utils/rights';
|
||||
@ -20,6 +21,7 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
|
||||
setSelectedEstablishmentEvaluationFrequency,
|
||||
setSelectedEstablishmentTotalCapacity,
|
||||
} = useEstablishment();
|
||||
const { isConnected, connectionStatus } = useChatConnection();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
@ -60,6 +62,36 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
|
||||
(est) => est.role_id === selectedRoleId
|
||||
);
|
||||
|
||||
// Fonction pour obtenir la couleur de la bulle de statut
|
||||
const getStatusColor = () => {
|
||||
switch (connectionStatus) {
|
||||
case 'connected':
|
||||
return 'bg-green-500';
|
||||
case 'connecting':
|
||||
return 'bg-yellow-500';
|
||||
case 'error':
|
||||
return 'bg-red-500';
|
||||
case 'disconnected':
|
||||
default:
|
||||
return 'bg-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
// Fonction pour obtenir le titre de la bulle de statut
|
||||
const getStatusTitle = () => {
|
||||
switch (connectionStatus) {
|
||||
case 'connected':
|
||||
return 'Chat connecté';
|
||||
case 'connecting':
|
||||
return 'Connexion au chat...';
|
||||
case 'error':
|
||||
return 'Erreur de connexion au chat';
|
||||
case 'disconnected':
|
||||
default:
|
||||
return 'Chat déconnecté';
|
||||
}
|
||||
};
|
||||
|
||||
// Suppression du tronquage JS, on utilise uniquement CSS
|
||||
const isSingleRole = establishments && establishments.length === 1;
|
||||
|
||||
@ -68,13 +100,20 @@ const ProfileSelector = ({ onRoleChange, className = '' }) => {
|
||||
<DropdownMenu
|
||||
buttonContent={
|
||||
<div className="h-16 flex items-center gap-2 cursor-pointer px-4 bg-white">
|
||||
<Image
|
||||
src={getGravatarUrl(user?.email)}
|
||||
alt="Profile"
|
||||
className="w-8 h-8 rounded-full mr-2"
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
<div className="relative">
|
||||
<Image
|
||||
src={getGravatarUrl(user?.email)}
|
||||
alt="Profile"
|
||||
className="w-10 h-10 rounded-full object-cover shadow-md"
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
{/* Bulle de statut de connexion au chat */}
|
||||
<div
|
||||
className={`absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 rounded-full border-2 border-white ${getStatusColor()}`}
|
||||
title={getStatusTitle()}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="font-bold text-left truncate max-w-full"
|
||||
|
||||
@ -6,6 +6,7 @@ import { NextIntlClientProvider } from 'next-intl';
|
||||
import { EstablishmentProvider } from '@/context/EstablishmentContext';
|
||||
import { NotificationProvider } from '@/context/NotificationContext';
|
||||
import { ClassesProvider } from '@/context/ClassesContext';
|
||||
import { ChatConnectionProvider } from '@/context/ChatConnectionContext';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import logger from '@/utils/logger';
|
||||
@ -23,11 +24,13 @@ export default function Providers({ children, messages, locale, session }) {
|
||||
<CsrfProvider>
|
||||
<EstablishmentProvider>
|
||||
<ClassesProvider>
|
||||
<PopupProvider>
|
||||
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</PopupProvider>
|
||||
<ChatConnectionProvider>
|
||||
<PopupProvider>
|
||||
<NextIntlClientProvider messages={messages} locale={locale}>
|
||||
{children}
|
||||
</NextIntlClientProvider>
|
||||
</PopupProvider>
|
||||
</ChatConnectionProvider>
|
||||
</ClassesProvider>
|
||||
</EstablishmentProvider>
|
||||
</CsrfProvider>
|
||||
|
||||
@ -31,7 +31,7 @@ const SidebarTabs = ({ tabs, onTabChange }) => {
|
||||
</div>
|
||||
|
||||
{/* Tabs Content */}
|
||||
<div className="flex-1 overflow-y-auto rounded-b-lg shadow-inner relative">
|
||||
<div className="flex-1 flex flex-col overflow-hidden rounded-b-lg shadow-inner">
|
||||
<AnimatePresence mode="wait">
|
||||
{tabs.map(
|
||||
(tab) =>
|
||||
@ -42,7 +42,7 @@ const SidebarTabs = ({ tabs, onTabChange }) => {
|
||||
animate={{ opacity: 1, x: 0 }} // Animation visible
|
||||
exit={{ opacity: 0, x: -50 }} // Animation de sortie
|
||||
transition={{ duration: 0.3 }} // Durée des animations
|
||||
className="absolute w-full h-full"
|
||||
className="flex-1 flex flex-col h-full min-h-0"
|
||||
>
|
||||
{tab.content}
|
||||
</motion.div>
|
||||
|
||||
Reference in New Issue
Block a user